fix(security): SEC-20's exemptions become raised ceilings, and the 413 is dressed

The module admitted its two opt-outs under a stated rule — "both enforce their own
limit on the bytes they actually read, which is what makes opting them out safe" —
and that rule is false for one of them. `POST /api/downloads/upload` declares
`file: UploadFile = File(...)`, so FastAPI parses and spools the whole multipart
part to a temp file BEFORE the first dependency runs, `require_human` included:
its in-handler cap measures bytes that have already landed. Measured on the
installed fastapi 0.141.1 / starlette 1.6.0 — starlette's max_part_size guards
non-file parts only, so an anonymous chunked upload was bounded by nothing. In the
shipped self-host compose that lands in a RAM-backed tmpfs.

The exposure predates SEC-20 (no route was capped before it); what this branch
added was the claim. So the list becomes a mapping of per-route CEILINGS instead
of a set of exemptions: every request now gets a finite number, the upload route's
being the same one the route itself enforces. `sysconfig`'s knob is bounded by it
so an admin cannot raise the handler's cap past the edge and start collecting 413s
from a layer the admin page never mentions — held by a test, not by prose.

Two more things the review caught in the same module:

The 413 was not byte-identical to `capped_body`'s after all. `json.dumps` puts
spaces where JSONResponse does not, and the test that promised to hold them
together compared PARSED JSON, so it could never have seen it. Built through the
same JSONResponse now, and compared as raw bytes.

And the middleware sat outside CORS and the security headers, so its 413 carried
neither — on the split-origin dev setup the browser drops it and the user gets a
generic network failure instead of the translated too_large, while the identical
refusal from capped_body one layer down renders fine. It moves to just inside
CORS: still ahead of the session, the setup gate and every auth dependency.

Also corrected: the docblock's "largest non-exempt body is 64 KiB, ~16x headroom"
(it is 512 KiB and 2x — the diagnostics sink, named eleven lines further down),
and CLAUDE.md's Request SIZE row, which forbade the one correct use of
capped_body that is actually in the tree.
This commit is contained in:
2026-09-21 23:32:49 +02:00
parent bf518223e5
commit 05121fbc99
5 changed files with 172 additions and 76 deletions
+15 -9
View File
@@ -190,6 +190,19 @@ app.add_middleware(
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
)
# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL
# before the first dependency, so without this the anonymous login route is a free memory/time sink.
#
# WHERE it sits is the whole design. Registered directly after SessionMiddleware, so — last
# registered being outermost — it ends up just INSIDE CORS and just OUTSIDE the session: the 413 is
# decorated by CORS and the security headers like every other response (a refusal a browser cannot
# read is no better than a hang, and `capped_body`'s 413 one layer down is decorated), while the
# session cookie, the setup gate and every auth dependency under the router still never run for a
# body that is already too large. Registering it outside CORS instead would answer a split-origin
# caller with a response the browser drops — two shapes for one fact, which is exactly what
# bodylimit.py exists to avoid.
app.add_middleware(bodylimit.BodyLimitMiddleware)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
@@ -258,17 +271,10 @@ async def security_headers(request, call_next):
return response
# SEC-20 — the global request-body cap (app/bodylimit.py). Registered SECOND-TO-LAST, i.e. directly
# INSIDE the access log: a refusal is still logged with its request id and duration, but everything
# below this line — the session, CORS, the setup gate, and every auth dependency under the router —
# never runs for a body that is already too large. FastAPI reads a declared body in full before the
# first dependency, so without this the anonymous login route is a free memory/time sink.
app.add_middleware(bodylimit.BodyLimitMiddleware)
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
# request, including what every layer above does — the body cap included, so a 413 is still logged
# with its request id and duration. Being outermost also puts it OUTSIDE SessionMiddleware,
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
# place a session is actually resolved into an account) rather than from `request.session`.
app.middleware("http")(log_requests)