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
+18 -5
View File
@@ -140,10 +140,12 @@ SPECS: tuple[ConfigSpec, ...] = (
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over this same
# number at the edge, before the route's own check ever sees it. An admin allowed to raise the
# knob past the edge would get 413s from a layer the admin page never mentions, so the two are
# pinned together here and `tests/test_body_limit.py` holds them that way.
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over roughly
# this number at the edge, before the route's own check ever sees it. An admin allowed to raise
# the knob past the edge would get 413s from a layer the admin page never mentions, so the two
# are pinned together here and `tests/test_body_limit.py` holds them that way. `_coerce` clamps
# a stored value to this on READ too — this bound did not always exist, so a legal-when-written
# override can be sitting in the database already.
ConfigSpec(
"download_upload_max_bytes",
"int",
@@ -249,7 +251,18 @@ def _choice(s: ConfigSpec, raw: object) -> str:
def _coerce(s: ConfigSpec, raw: str):
if s.type == "int":
return int(raw)
# CLAMPED, not merely validated. `set_value` rejects an out-of-range int, but bounds get
# ADDED to a spec (download_upload_max_bytes gained a max when SEC-20 started enforcing the
# same number at the edge) and env defaults get LOWERED, and either leaves a stored value
# that was legal when it was written. Serving it unclamped is how one layer ends up acting
# on a number another layer refuses — with the admin page happily showing a value outside
# the range it renders. Clamping keeps the promise the bounds make rather than restating it.
v = int(raw)
if s.min is not None:
v = max(v, s.min)
if s.max is not None:
v = min(v, s.max)
return v
if s.type == "bool":
return raw == "true"
if s.type == "choice":