Skip to content

Commit 3ef4561

Browse files
vagkaratzasclaude
andcommitted
fix: reject oversized request bodies via Content-Length before parsing
JSON body endpoints (layout/topology/external) only rejected oversized node/edge lists after Starlette buffered and Pydantic parsed the full body. Add global middleware that 413s early based on declared Content-Length. Backstop for direct backend exposure (docker-compose dev) — nginx's client_max_body_size already covers the prod path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2a4ace2 commit 3ef4561

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

backend/app/main.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from fastapi import FastAPI
1+
from fastapi import FastAPI, Request
2+
from fastapi.responses import JSONResponse
23

4+
from app import config
35
from app.routers import attributes as attributes_router
46
from app.routers import config as config_router
57
from app.routers import external as external_router
@@ -18,6 +20,27 @@
1820
app.include_router(attributes_router.router)
1921

2022

23+
# Defense-in-depth: JSON body endpoints (layout/topology/external) validate
24+
# node/edge counts only *after* Starlette buffers + Pydantic parses the full
25+
# body, so an oversized payload is fully read into memory before rejection.
26+
# Reject early using the declared Content-Length. This is a backstop for
27+
# direct/non-nginx exposure (e.g. docker-compose dev) — the prod deploy's
28+
# nginx client_max_body_size already enforces this at the edge. A request
29+
# without Content-Length (chunked transfer) isn't caught here; it still hits
30+
# the per-endpoint UploadFile checks for the file-upload routes.
31+
@app.middleware("http")
32+
async def limit_body_size(request: Request, call_next): # type: ignore[no-untyped-def]
33+
content_length = request.headers.get("content-length")
34+
if content_length is not None:
35+
try:
36+
too_large = int(content_length) > config.MAX_UPLOAD_BYTES
37+
except ValueError:
38+
too_large = False # malformed header — let normal parsing reject it
39+
if too_large:
40+
return JSONResponse(status_code=413, content={"detail": "Payload too large."})
41+
return await call_next(request)
42+
43+
2144
@app.get("/api/health")
2245
async def health() -> dict[str, str]:
2346
return {"status": "ok"}

backend/tests/test_main.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fastapi.testclient import TestClient
2+
3+
from app import config
4+
from app.main import app
5+
6+
client = TestClient(app)
7+
8+
9+
def test_oversized_content_length_rejected_before_parsing() -> None:
10+
headers = {"content-length": str(config.MAX_UPLOAD_BYTES + 1)}
11+
resp = client.post("/api/layout", headers=headers, content=b"")
12+
assert resp.status_code == 413
13+
14+
15+
def test_undersized_content_length_reaches_normal_validation() -> None:
16+
# No node/edge lists -> 422 from Pydantic, proving the middleware let it
17+
# past the size gate rather than blocking every request.
18+
resp = client.post("/api/layout", json={})
19+
assert resp.status_code == 422
20+
21+
22+
def test_malformed_content_length_is_ignored() -> None:
23+
resp = client.post("/api/layout", headers={"content-length": "not-a-number"}, json={})
24+
assert resp.status_code == 422

0 commit comments

Comments
 (0)