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:
2026-09-22 01:59:03 +02:00
parent b82c22b888
commit f5018bdcbc
10 changed files with 396 additions and 109 deletions
+21 -10
View File
@@ -372,16 +372,27 @@ export async function req<T = unknown>(
}
}
// Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a
// file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an
// optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab
// account header, credentials, the structured-detail localization, the global error modal for
// 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call.
// The browser sets the multipart Content-Type (with its boundary), so we deliberately don't.
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
// `body` is a `Blob`/`File` for a raw upload or `FormData` for a multipart one. The raw shape is
// what `/api/downloads/upload` uses since P1, and it is not a preference: a multipart part is a
// DECLARED body, which FastAPI spools before the route's auth dependency runs. `cfg.headers` is how
// such a caller sends the metadata that used to be sibling form fields.
//
// Content-Type is never set here. The browser derives it — the multipart boundary for FormData, the
// Blob's own type otherwise — and overriding it would break the boundary.
export function uploadReq<T = unknown>(
url: string,
form: FormData,
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
body: Blob | FormData,
cfg: {
onProgress?: (pct: number) => void;
signal?: AbortSignal;
headers?: Record<string, string>;
} = {},
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
@@ -390,7 +401,7 @@ export function uploadReq<T = unknown>(
const requestId = newRequestId();
const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
for (const [name, value] of Object.entries(identityHeaders()))
for (const [name, value] of Object.entries({ ...identityHeaders(), ...cfg.headers }))
xhr.setRequestHeader(name, value);
if (cfg.onProgress) {
xhr.upload.onprogress = (e) => {
@@ -443,6 +454,6 @@ export function uploadReq<T = unknown>(
logRequest(requestId, "POST", url, started, { failed: "aborted" });
reject(new HttpError(0, ABORTED));
};
xhr.send(form);
xhr.send(body);
});
}