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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user