Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix missing resource limits in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Bound every resource the client controls. Cap the request body while you read it -- count the stream and return 413 as soon as it exceeds the limit, never trusting Content-Length -- and bound pagination with Query(limit, ge=1, le=100) so an out-of-range page size is a 422. Add timeouts and rate limits at the edge too.

On this page

Steps

  1. List what a request can make the server allocate: body bytes, number of items returned, query cost, concurrent work.
  2. Cap the request body as you read it: iterate request.stream(), count the bytes, and raise HTTPException(413) the moment the total exceeds the limit, so the oversized body is never fully buffered.
  3. Do not rely on Content-Length for the size check: a chunked request omits it and a client can understate it; the stream count is the real limit.
  4. Bound pagination with validated query parameters: limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), so an out-of-range value is rejected with 422 before any rows are fetched.
  5. Set a server/proxy body-size limit and request timeouts as well, and apply rate limiting at the edge; application caps and infrastructure caps are complementary.
  6. Add regression tests for an oversized body (both with an honest Content-Length and sent chunked) and for an over-large and a negative page size.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def add_routes(app: FastAPI) -> None:
    @app.post("/notes", status_code=201)
    async def create_note(request: Request):
        body = await request.body()  # reads the entire body, however large
        content = parse_content(body)
        app.state.notes.append(content)
        return {"id": len(app.state.notes) - 1, "bytes": len(content)}

    @app.get("/notes")
    def list_notes(limit: int = Query(DEFAULT_LIMIT), offset: int = Query(0)):
        items = app.state.notes[offset : offset + limit]  # no cap on limit
        return {"items": items, "count": len(items)}

With IMPL=vulnerable, 4 of 8 tests fail (4 exploit tests).

The fix: cap the body while reading and bound pagination

Fixedimpl/fixed.pyPython
def add_routes(app: FastAPI) -> None:
    @app.post("/notes", status_code=201)
    async def create_note(request: Request):
        body = bytearray()
        async for chunk in request.stream():
            body += chunk
            if len(body) > MAX_BYTES:  # enforced while reading, not from Content-Length
                raise HTTPException(status_code=413, detail="note too large")
        content = parse_content(bytes(body))
        app.state.notes.append(content)
        return {"id": len(app.state.notes) - 1, "bytes": len(content)}

    @app.get("/notes")
    def list_notes(
        limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
        offset: int = Query(0, ge=0),
    ):
        items = app.state.notes[offset : offset + limit]
        return {"items": items, "count": len(items)}

With IMPL=fixed, 8 of 8 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def add_routes(app: FastAPI) -> None:
    @app.post("/notes", status_code=201)
    async def create_note(request: Request):
        declared = request.headers.get("content-length")
        if declared is not None and int(declared) > MAX_BYTES:
            raise HTTPException(status_code=413, detail="note too large")
        # The header is advisory: a chunked request omits it, so this still buffers everything.
        body = await request.body()
        content = parse_content(body)
        app.state.notes.append(content)
        return {"id": len(app.state.notes) - 1, "bytes": len(content)}

    @app.get("/notes")
    def list_notes(limit: int = Query(DEFAULT_LIMIT), offset: int = Query(0)):
        items = app.state.notes[offset : offset + limit]
        return {"items": items, "count": len(items)}

With IMPL=wrong, 3 of 8 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_limits.pyPython
# Exploit: unbounded body size and unbounded page size.

def test_exploit_oversized_body_with_honest_length_is_refused(client):
    res = client.post("/notes", content=note_body(MAX_BYTES * 4), headers={"content-type": "application/json"})
    assert res.status_code == 413


def test_exploit_oversized_body_sent_chunked_is_refused(client):
    payload = note_body(MAX_BYTES * 4)

    def chunks():
        for i in range(0, len(payload), 1024):
            yield payload[i : i + 1024]

    # A generator body makes httpx use chunked transfer encoding and omit Content-Length.
    res = client.post("/notes", content=chunks(), headers={"content-type": "application/json"})
    assert res.status_code == 413, "a chunked oversized body slipped past the size check"


def test_exploit_huge_limit_is_refused(client):
    res = client.get("/notes", params={"limit": 100_000_000})
    assert res.status_code == 422


def test_exploit_negative_limit_is_refused(client):
    res = client.get("/notes", params={"limit": -1})
    assert res.status_code == 422

Pitfalls

  • Checking int(request.headers['content-length']) and then calling await request.body() still buffers the whole body: a chunked request has no Content-Length, so the check is skipped and the limit is not enforced.
  • FastAPI and Starlette do not cap the request body by default; declaring a Pydantic model does not limit its size, because the body is read before (or as) it is validated.
  • min(limit, MAX) clamps the response but may still fetch limit rows from the data store; validate the input with Query bounds so an absurd limit is rejected, not silently fetched.
  • A negative or zero limit/offset can cause odd slices or expensive queries; constrain them (ge=1 for limit, ge=0 for offset).
  • A blocking read of a huge body in an async handler ties up the event loop; capping the size protects latency as well as memory.
  • Body size and page size are only two resources; unbounded regex, recursion, decompression (zip bombs) and fan-out need their own limits.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs resource-limits/python-fastapi does this for you).
  2. Against your own endpoint, POST a body over the limit with an honest Content-Length and again sent chunked (no Content-Length); both must return 413. Request a page with limit=100000000 and limit=-1; both must return 422.
  3. No scanner checked here flags an unbounded body or page size; the regression tests are the control. In production, confirm the proxy/server body-size limit and timeouts are set as a second layer.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing resource limits in Python and FastAPI
ToolRuleWhat it found here
Semgrepp/python, p/owasp-top-ten, p/security-audit, p/fastapiNo findings on any implementation. No rule flags an unbounded request body, a Content-Length-only size check or an unbounded pagination parameter. (The registry's only throttling rule, missing-throttle-config, is for Django REST framework.)Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/fastapi --exclude tests .
Banditall plugins (default profile)Bandit 1.9.4No findings in any implementation. Bandit has no plugin for request-size or pagination limits, so its silence says nothing about this weakness.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
starlette1.7.0
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the resource-limits/python-fastapifolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs resource-limits/python-fastapi runs all three modes and checks the expected results.

Download the project ZIP, 17 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • fastapi 0.142.2
  • starlette 1.7.0
  • pydantic 2.13.5
  • httpx2 2.13.1
  • pytest 9.1.1
Matrix
fixed: 8 of 8 tests pass; vulnerable: 4 of 8 tests fail (4 exploit tests); wrong: 3 of 8 tests fail (3 exploit tests)
Test results
IMPL=fixed 8/8 pass; IMPL=vulnerable 4/8 fail (all 4 exploit: the honest and chunked oversized bodies, the huge limit, the negative limit); IMPL=wrong 3/8 fail (3 exploit: the chunked oversized body -- which omits Content-Length -- the huge limit and the negative limit; the honest oversized body is caught by the header check).
Dependency freshness
fastapi 0.142.2 (2026-09-30), starlette 1.7.0 (2026-09-23), pydantic 2.13.5 (2026-08-28) match the other FastAPI cards (0.143.0 changed OpenTelemetry defaults; pydantic 2.14.x were under a week old). The chunked-body behavior (httpx omits Content-Length for a generator body, so Starlette's request has no content-length header) was verified empirically. Python 3.14.8 is the 30 September 2026 security release.
Scanners
Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); no CodeQL query covers this weakness.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-770: Allocation of Resources Without Limits or Throttling
  2. CWE-400: Uncontrolled Resource Consumption
  3. OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption
  4. Starlette: Requests (stream, body)
  5. FastAPI: Query parameters and string/number validations

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.