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:
@@ -64,11 +64,20 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
||||
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
|
||||
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
|
||||
# default, so listing it could only widen what it accepts.
|
||||
# What a multipart envelope costs on top of the file itself: the boundary lines, the part headers
|
||||
# and the sibling `display_name` field. The edge counts RAW BODY bytes while the upload route counts
|
||||
# the DECODED file, so without this allowance a file of exactly the configured maximum — the number
|
||||
# the admin page advertises, and the first thing anyone testing the limit reaches for — is refused
|
||||
# one layer before the route that would have accepted it. Generous on purpose: its job is to keep
|
||||
# the two layers from disagreeing at the boundary, not to be a second limit.
|
||||
MULTIPART_ENVELOPE_ALLOWANCE = 64 * 1024
|
||||
|
||||
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
||||
# Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded by this
|
||||
# same setting (see sysconfig.SPECS), so the admin knob can never ask for more than the edge
|
||||
# will pass — the two numbers cannot drift into refusing a legitimate upload.
|
||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
||||
# Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded so it can
|
||||
# never exceed this (see sysconfig.SPECS), which is what stops an admin from raising the route's
|
||||
# own cap past the edge and collecting 413s from a layer the admin page never mentions.
|
||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes
|
||||
+ MULTIPART_ENVELOPE_ALLOWANCE,
|
||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args, get_origin
|
||||
|
||||
from pydantic import ValidationInfo, field_validator, model_validator
|
||||
from pydantic import Field, ValidationInfo, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||
@@ -247,7 +247,11 @@ class Settings(BaseSettings):
|
||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||
download_upload_max_bytes: int = 524_288_000 # 500 MiB
|
||||
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
|
||||
# real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0
|
||||
# would refuse every upload, including the admin page's own way back. To lift the cap in
|
||||
# practice, set a large number.
|
||||
download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB
|
||||
# Retention: an asset expires this many days after its last access; the GC job then
|
||||
# deletes it (once no job references it) after an additional grace window.
|
||||
download_retention_days: int = 30
|
||||
|
||||
+28
-22
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -12,12 +12,15 @@ gets through": the first alone would pass just as well if the cap refused *every
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
from app import bodylimit, sysconfig
|
||||
from app import bodylimit, debugsession, sysconfig
|
||||
from app.models import SystemConfig
|
||||
from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware
|
||||
from app.routes import diagnostics as diagnostics_routes
|
||||
|
||||
@@ -186,27 +189,99 @@ def test_every_listed_template_still_resolves_to_a_real_route(method, template):
|
||||
|
||||
|
||||
def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
|
||||
"""The headroom the module's docblock claims, held as a ratio rather than as a figure in prose.
|
||||
"""The headroom the module's docblock claims, held as a RATIO rather than as a figure in prose.
|
||||
The client log sink caps itself at 512 KiB — below the default, which is why it is not listed:
|
||||
the global ceiling never fires on it and raising it could only widen what it accepts."""
|
||||
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
|
||||
the global ceiling never fires on it and raising it could only widen what it accepts.
|
||||
|
||||
`<=` alone would not hold the claim. The docblock calls the 2x margin load-bearing precisely
|
||||
because a ceiling that refuses a legitimate request is a worse bug than the one this module
|
||||
fixes, and a margin of 1x is no margin at all.
|
||||
"""
|
||||
assert diagnostics_routes.BODY_MAX_BYTES * 2 <= DEFAULT_MAX_BODY_BYTES
|
||||
assert not any(
|
||||
path.startswith("/api/diagnostics") for _, path in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||
)
|
||||
|
||||
|
||||
# Every `capped_body` call site in the routers, as (module, limit constant). A route that reads its
|
||||
# own body declares the size it MEANS to accept; if that is above the global default and its route
|
||||
# is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have taken —
|
||||
# silently, in production, on a path nobody tested. The point of the list is the third entry.
|
||||
CAPPED_BODY_SITES = {
|
||||
("diagnostics.py", "BODY_MAX_BYTES"),
|
||||
("debug_sessions.py", "debugsession.SCREENSHOT_MAX_BYTES"),
|
||||
}
|
||||
|
||||
|
||||
def test_every_capped_body_site_is_known_and_fits_under_its_ceiling():
|
||||
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
|
||||
found = {
|
||||
(py.name, m.group(1))
|
||||
for py in routes_dir.rglob("*.py")
|
||||
for m in re.finditer(
|
||||
r"capped_body\(\s*request\s*,\s*([A-Za-z_][\w.]*)", py.read_text("utf-8")
|
||||
)
|
||||
}
|
||||
assert found == CAPPED_BODY_SITES, (
|
||||
"a capped_body call site appeared or moved. Each one declares a body size; check it against "
|
||||
"bodylimit.DEFAULT_MAX_BODY_BYTES and, if it is larger, add its route to "
|
||||
"bodylimit.ROUTE_MAX_BODY_BYTES — then update this list."
|
||||
)
|
||||
# And the two known ones really do satisfy the rule.
|
||||
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
|
||||
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||
|
||||
|
||||
def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
|
||||
"""The upload route's in-handler cap is a `sysconfig` value an admin can change; this middleware
|
||||
is a constant. If the knob could be set above the ceiling, an admin raising it would silently
|
||||
start getting 413s from a layer the admin page never mentions. The spec's `max` is what stops
|
||||
that, and it is the same number."""
|
||||
"""The upload route's in-handler cap is a `sysconfig` value an admin can change; the edge is a
|
||||
constant. If the knob could be set above the edge, an admin raising it would silently start
|
||||
getting 413s from a layer the admin page never mentions.
|
||||
|
||||
The relation is `<=`, not `==`, and the gap is the point: the edge counts RAW body bytes while
|
||||
the route counts the DECODED file, so the edge has to allow for the multipart envelope or a file
|
||||
of exactly the advertised maximum is refused one layer before the route that would take it.
|
||||
"""
|
||||
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||
assert spec.max == bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
|
||||
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
|
||||
assert spec.max is not None and spec.max <= edge
|
||||
assert edge - spec.max == bodylimit.MULTIPART_ENVELOPE_ALLOWANCE
|
||||
|
||||
|
||||
def test_a_stored_override_above_the_max_is_clamped_on_READ(db):
|
||||
"""`set_value` refuses an out-of-range int, but this bound did not always exist — before SEC-20
|
||||
the upload knob had no `max` at all — so a value that was legal when it was written can be
|
||||
sitting in the database. Served unclamped it is exactly the drift the test above forbids: the
|
||||
route would accept bytes the edge refuses."""
|
||||
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||
assert spec.max is not None
|
||||
db.add(SystemConfig(key="download_upload_max_bytes", value=str(spec.max * 4)))
|
||||
db.commit()
|
||||
assert sysconfig.get_int(db, "download_upload_max_bytes") == spec.max
|
||||
|
||||
|
||||
def test_the_cap_sits_inside_the_response_decorators_and_outside_the_setup_gate():
|
||||
"""The registration order, held in code rather than in the comment beside it. `add_middleware`
|
||||
inverts the reading order (last registered = OUTERMOST), which is exactly the kind of thing a
|
||||
prose description gets backwards — it already did once.
|
||||
|
||||
Outside the cap: the response decorators, so the 413 is dressed like any other answer. Inside
|
||||
it: everything that does work on the caller's behalf — the setup gate opens a DB session per
|
||||
request while an instance is unconfigured, and that is the anonymous pre-auth work SEC-20 exists
|
||||
to remove.
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
names = [getattr(m.cls, "__name__", str(m.cls)) for m in app.user_middleware]
|
||||
i = names.index("BodyLimitMiddleware")
|
||||
# Outer→inner, so a smaller index is further out.
|
||||
assert i > 0, "the cap is the outermost middleware — the access log must stay outside it"
|
||||
assert names[i + 1 :] and "SessionMiddleware" in names[i + 1 :]
|
||||
# The setup gate and every auth dependency are inside; only decorators are outside.
|
||||
assert all(n == "BaseHTTPMiddleware" or n == "CORSMiddleware" for n in names[:i])
|
||||
|
||||
|
||||
def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces():
|
||||
from app import debugsession
|
||||
|
||||
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
|
||||
assert (
|
||||
bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/debug-sessions/{sid}/screenshots")]
|
||||
@@ -217,12 +292,22 @@ def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces():
|
||||
# --- the real application -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_global_refusal_carries_the_baseline_security_headers(client):
|
||||
def test_the_global_refusal_carries_the_baseline_security_headers():
|
||||
"""The 413 is a response like any other, so it has to be dressed like one. The middleware sits
|
||||
INSIDE the security-header and CORS layers for exactly this reason: a refusal a browser drops
|
||||
for a missing header is indistinguishable from a network failure, and the user would be shown a
|
||||
generic error instead of the translated `too_large`."""
|
||||
r = client.post("/auth/password-login", content=OVER, headers={"content-type": "text/plain"})
|
||||
generic error instead of the translated `too_large`.
|
||||
|
||||
A bare TestClient on purpose, not the `client` fixture: this is the only test that exercises the
|
||||
middleware ORDER, and that fixture is DB-gated — so on a host without Postgres the one assertion
|
||||
holding the move would have skipped while the file still reported green. The refusal never
|
||||
reaches a database; it is answered above the router.
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
r = TestClient(app).post(
|
||||
"/auth/password-login", content=OVER, headers={"content-type": "text/plain"}
|
||||
)
|
||||
assert r.status_code == 413
|
||||
assert r.headers.get("x-content-type-options") == "nosniff"
|
||||
assert "referrer-policy" in r.headers
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user