fix(security): round 2 — the SEC-20 fix had four defects of its own

A second review round over the first round's fixes, which is where the remaining
real bugs usually are. All four are the same failure as the round it reviewed: a
claim stated in a comment that the code does not hold.

The middleware moved inside CORS, but the setup gate is registered LATER and is
therefore OUTSIDE it — so the comment's "the setup gate never runs for a body that
is already too large" was false the moment it was written. On an unconfigured
instance that gate opens a DB session per request, which is exactly the anonymous
pre-auth work SEC-20 exists to remove. The cap now sits between them, and the
order is asserted against `app.user_middleware` rather than described: prose about
a stack that `add_middleware` builds inside-out is how this got backwards.

The edge ceiling and the route's own cap were pinned EQUAL, but they measure
different things — the edge counts raw body bytes, the route counts the decoded
file. A file of exactly the advertised maximum was refused one layer before the
route that would have taken it. The edge now allows for the multipart envelope,
and the test asserts the relation and the gap instead of equality.

`sysconfig`'s new `max` was enforced on write only. That bound did not exist
before this epic, so a value that was legal when it was written can be sitting in
the database — served unclamped it produces exactly the drift the comment says is
impossible, with the admin page showing a number outside its own range. Int
overrides are clamped on read now, which also survives an env default being
lowered later.

And `DOWNLOAD_UPLOAD_MAX_BYTES=0` meant "no per-file cap" to the route but a hard
zero to the edge: every upload refused, including the admin page's own way back,
recoverable only by editing env and restarting. The setting takes `ge=1`.

Two test gaps from the same round: the header assertion that is the ONLY check of
the middleware's position was DB-gated and skipped on a host without Postgres, and
the headroom "ratio" the docblock calls load-bearing was held as `<=`, i.e. no
ratio at all. Both fixed, plus a sweep that every `capped_body` call site in the
routers is known and fits under its ceiling — the point of that list is the third
entry, not the two that exist today.
This commit is contained in:
2026-09-21 23:59:45 +02:00
parent 378b7559e4
commit aa51644f5f
6 changed files with 164 additions and 48 deletions
+28 -22
View File
@@ -190,28 +190,6 @@ 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,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
@@ -231,6 +209,34 @@ async def setup_gate(request, call_next):
return await call_next(request)
# 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, and it is pinned by `test_body_limit.py` rather than by this
# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a
# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly
# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints:
#
# * inside CORS and the security headers, so the 413 is decorated like every other response. A
# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang,
# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what
# bodylimit.py exists to avoid.
# * outside the setup gate, the session and every auth dependency under the router — none of which
# run for a body that is already too large. The setup gate matters specifically: on an instance
# that is not yet configured it opens a DB session per request, which is precisely the anonymous
# pre-auth work SEC-20 exists to remove.
app.add_middleware(bodylimit.BodyLimitMiddleware)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def static_cache_headers(request, call_next):
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given