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.
- List what a request can make the server allocate: body bytes, number of items returned, query cost, concurrent work.
- 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.
- 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.
- 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.
- 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.
- 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.
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
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
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
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 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.
# 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- 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
limitrows 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/owasp-top-ten, p/security-audit, p/fastapi | No 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 . |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No 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 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.142.2 |
| starlette | 1.7.0 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| pytest | 9.1.1 |
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.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# 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 pytestEach 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.
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
- CWE-770: Allocation of Resources Without Limits or Throttling
- CWE-400: Uncontrolled Resource Consumption
- OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption
- Starlette: Requests (stream, body)
- FastAPI: Query parameters and string/number validations
Attack patterns: