fix(uploads): read the body after authenticating, not before it
`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.
This commit is contained in:
+21
-20
@@ -53,31 +53,32 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
||||
#
|
||||
# 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 holds for the screenshot route (no declared body
|
||||
# parameter: `capped_body` streams it, and the owner check resolves first) and it does NOT hold for
|
||||
# the upload route: `file: UploadFile = File(...)` is a declared body, so FastAPI parses and spools
|
||||
# the whole multipart part to a temp file BEFORE the first dependency runs — `require_human`
|
||||
# included. Its in-handler cap therefore measures bytes that have already landed. Left uncounted,
|
||||
# an anonymous caller could stream without bound into the container's tmpfs; counted here, the
|
||||
# process refuses at the same number the route itself enforces, before any of it is stored.
|
||||
# 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.
|
||||
# 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 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,
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user