`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and FastAPI parses and spools a declared body before the first dependency runs -- `require_human` included. An unauthenticated caller could therefore make the process write the whole per-file allowance (500 MiB by default) to temp, once per request. SEC-20's ceiling bounded that number but could not stop the writing, which is why E27 parked it rather than closing it. The route now takes no body parameter. `spool_upload` names `require_human` first and only then streams `request.stream()` to the staging temp file, so a stranger is refused before a byte moves -- the shape the screenshot route has used since E16. Filename and display name travel as percent-encoded headers (latin-1 on the wire, so a Hungarian filename needs the encoding) instead of multipart parts. `capped_stream_to` joins `capped_body` in routes/_common.py: the same counting rule, written to a file descriptor rather than accumulated, because holding 500 MiB would trade a disk problem for a memory one. Its refusal is the caller's, so the upload route keeps answering 422 `upload_too_large` with the admin knob's own number. With no envelope to allow for, the edge is now exactly that knob, and MULTIPART_ENVELOPE_ALLOWANCE is gone. The invariant stops being prose: test_body_limit.py asks FastAPI itself (`route.dependant.body_params`) and fails the gate if any route with a raised ceiling declares a body. A comment could not have prevented the defect it described; that test would have. Both new tests were seen red for the right reason -- before the fix the anonymous probe got its 401 only after the server had read 4236 bytes of a multipart body, and re-declaring the File parameter turns the invariant test red naming the route and the parameter.
217 lines
11 KiB
Python
217 lines
11 KiB
Python
"""The global request-body cap (SEC-20).
|
|
|
|
**The hole.** FastAPI reads a declared body IN FULL before auth or any other dependency runs — the
|
|
body parameter has to be parsed to be passed in. So `POST /auth/password-login` drains a 32 MiB
|
|
upload from an anonymous caller and only then answers 401. 70 of the 72 body-declaring routes behave
|
|
the same way: one unauthenticated client can spend the process's memory and time at will.
|
|
|
|
**Why a middleware and not `capped_body` on every route.** `routes/_common.py:capped_body` is the
|
|
right tool where a route genuinely takes bulk bytes, and the two routes that use it use it
|
|
correctly. But putting it on all 70 others would (a) fight the canonical "Request body → a Pydantic
|
|
model" row, since capping means taking the body as raw bytes and parsing it by hand, and (b) leave
|
|
the NEXT route added uncovered, because nothing would remind its author. A default that has to be
|
|
opted into is not a default. This module inverts it: the cap applies to everything, and the few
|
|
routes that need more opt OUT, in one list, next to the code that reads it.
|
|
|
|
**Why raw ASGI and not `@app.middleware("http")`.** Starlette's BaseHTTPMiddleware cannot wrap
|
|
`receive`, so it can only read `Content-Length` — which a client may omit entirely (chunked) or
|
|
simply understate. The bytes have to be counted as they arrive, which is the same rule `capped_body`
|
|
already applies per-route, and counting needs the receive channel.
|
|
|
|
**Why the refusal is byte-identical to `capped_body`'s.** Two shapes for one fact is how the
|
|
`cancelled`/`canceled` bug happened. The 413 body here is built through the same `JSONResponse`
|
|
FastAPI renders `HTTPException(413, {"code": "too_large"})` with — not a hand-rolled `json.dumps`,
|
|
whose default separators differ by two bytes — and `tests/test_body_limit.py` compares the RAW BYTES
|
|
of the two against each other rather than against a literal, so they cannot drift apart. The same
|
|
reasoning decides where the middleware is registered (`main.py`): just inside CORS, so this 413
|
|
carries the same headers `capped_body`'s does.
|
|
|
|
**No admin knob, deliberately.** The ceiling is a constant, not a `sysconfig` value: an attacker-
|
|
facing limit that an admin can raise from the UI is a limit that can be raised by whoever gets the
|
|
admin session, and nobody has asked to tune it. If a self-hoster ever needs a different ceiling it
|
|
belongs in `config.py` (env, set before the process starts), not in the database.
|
|
"""
|
|
|
|
import re
|
|
|
|
from starlette.responses import JSONResponse
|
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
|
|
from app import debugsession
|
|
from app.config import settings
|
|
|
|
# 1 MiB. The largest body any route on the default ceiling legitimately takes is 512 KiB (the client
|
|
# log sink, `routes/diagnostics.py:BODY_MAX_BYTES`), so this leaves 2x headroom — a ceiling that
|
|
# refuses a legitimate request would be a worse bug than the one this module fixes. That ratio is
|
|
# the load-bearing fact, so `test_body_limit.py` holds it rather than the figure above.
|
|
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
|
|
|
# The routes that need MORE than the default, as (method, path TEMPLATE) → their own ceiling. The
|
|
# template, not a concrete path, so the list reads like the route decorators it mirrors and
|
|
# `test_body_limit.py` can assert that each one still resolves to a real route: a renamed route
|
|
# fails the gate instead of silently losing its allowance in prod.
|
|
#
|
|
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
|
|
# would mean "this route is counted by something else" — which is only true when the route's own
|
|
# limit applies to the bytes as they ARRIVE. That, in turn, is only true of a route with NO DECLARED
|
|
# BODY PARAMETER: FastAPI parses and spools a declared body BEFORE the first dependency runs, so a
|
|
# route that declares one has already stored the bytes by the time its own cap — or its auth — gets
|
|
# a say.
|
|
#
|
|
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
|
|
# and the owner check resolves first). The upload route did NOT until P1: it declared
|
|
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
|
|
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
|
|
# number but could not stop the writing. It now reads its own body from a dependency declared after
|
|
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
|
#
|
|
# That rule is no longer only written here: `test_body_limit.py` parses the routes and fails the
|
|
# gate if any route in this dict declares a body parameter. A comment could not have stopped the
|
|
# defect it describes; the test can.
|
|
#
|
|
# 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.
|
|
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
|
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
|
|
# so this is exactly the number the route enforces. `sysconfig`'s `download_upload_max_bytes` is
|
|
# bounded so it can never exceed it (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,
|
|
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
|
}
|
|
|
|
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
|
|
# as — built through the same JSONResponse so the separators match too, and asserted against a real
|
|
# route's raw bytes in `test_body_limit.py` rather than against a literal here.
|
|
TOO_LARGE_BODY = JSONResponse({"detail": {"code": "too_large"}}, status_code=413).body
|
|
|
|
_PATH_PARAM = re.compile(r"\{[^/{}]+\}")
|
|
|
|
|
|
def template_to_regex(template: str) -> re.Pattern[str]:
|
|
"""`/api/debug-sessions/{sid}/screenshots` → a regex matching one concrete path.
|
|
|
|
A path parameter matches `[^/]+` — one segment, never across a `/`, so `{sid}` cannot swallow a
|
|
deeper path and hand an exemption to a route that was never granted one.
|
|
"""
|
|
literals = [re.escape(part) for part in _PATH_PARAM.split(template)]
|
|
return re.compile("^" + "[^/]+".join(literals) + "$")
|
|
|
|
|
|
def _declared_length(scope: Scope) -> int | None:
|
|
"""The `Content-Length` header as an int, or None when absent or unparseable (a chunked body has
|
|
none, and a malformed one is not a number we may trust — both fall through to counting)."""
|
|
for name, value in scope.get("headers", ()):
|
|
if name == b"content-length":
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
# silent-ok: a malformed Content-Length is the caller's doing, not a fault of ours,
|
|
# and it is about to be answered anyway — returning None drops the request into the
|
|
# counting path, which refuses on the real bytes. Logging it would hand an anonymous
|
|
# client a free line in our log for every request it cares to malform.
|
|
return None
|
|
return None
|
|
|
|
|
|
async def _send_too_large(send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 413,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"content-length", str(len(TOO_LARGE_BODY)).encode()),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": TOO_LARGE_BODY})
|
|
|
|
|
|
class BodyLimitMiddleware:
|
|
"""Refuses any request body over its route's ceiling, before the application sees it.
|
|
|
|
Registered just inside CORS in `main.py` — so the 413 is decorated like every other response
|
|
and a browser can actually read it — but outside the session, the setup gate and every auth
|
|
dependency, none of which run for a request that is already too large.
|
|
|
|
Every request gets a finite ceiling: `ROUTE_MAX_BODY_BYTES` raises it for the two routes that
|
|
genuinely take bulk bytes, and there is no path that skips counting altogether.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
app: ASGIApp,
|
|
*,
|
|
limit: int = DEFAULT_MAX_BODY_BYTES,
|
|
routes: dict[tuple[str, str], int] | None = None,
|
|
) -> None:
|
|
self.app = app
|
|
self.limit = limit
|
|
self._routes = tuple(
|
|
(method, template_to_regex(path), cap)
|
|
for (method, path), cap in (ROUTE_MAX_BODY_BYTES if routes is None else routes).items()
|
|
)
|
|
|
|
def limit_for(self, method: str, path: str) -> int:
|
|
"""This request's ceiling: the route's own if it has one, else the global default. Every
|
|
request gets a finite number — there is no path through here that is simply not counted."""
|
|
for m, rx, cap in self._routes:
|
|
if m == method and rx.match(path):
|
|
return cap
|
|
return self.limit
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
limit = self.limit_for(scope.get("method", ""), scope.get("path", ""))
|
|
|
|
declared = _declared_length(scope)
|
|
if declared is not None and declared > limit:
|
|
# Refused on the declaration alone: nothing downstream is called, and not one byte of
|
|
# the body is read.
|
|
await _send_too_large(send)
|
|
return
|
|
|
|
seen = 0
|
|
refused = False
|
|
started = False
|
|
|
|
async def counting_receive() -> Message:
|
|
nonlocal seen, refused
|
|
message = await receive()
|
|
if message["type"] != "http.request":
|
|
return message
|
|
seen += len(message.get("body", b""))
|
|
if seen > limit:
|
|
# The app is blocked on this receive, so it has not answered yet. Telling it the
|
|
# client went away is the only in-band way to stop it reading: `request.stream()`
|
|
# turns an `http.disconnect` into ClientDisconnect, which unwinds the handler. Our
|
|
# own 413 goes out below, once the app has finished unwinding.
|
|
refused = True
|
|
return {"type": "http.disconnect"}
|
|
return message
|
|
|
|
async def guarded_send(message: Message) -> None:
|
|
nonlocal started
|
|
if refused and not started:
|
|
return # discard the app's answer to the disconnect; the 413 below is the response
|
|
started = True
|
|
await send(message)
|
|
|
|
try:
|
|
await self.app(scope, counting_receive, guarded_send)
|
|
except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
|
|
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
|
|
# consequence of the disconnect we just faked, and the caller must get the 413 rather
|
|
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
|
|
# untouched.
|
|
if not refused:
|
|
raise
|
|
|
|
if refused and not started:
|
|
await _send_too_large(send)
|