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
+27 -1
View File
@@ -23,6 +23,7 @@ vi.mock("../activeAccount", () => ({
}));
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
import {
beacon,
req,
@@ -46,6 +47,7 @@ class FakeXHR {
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
sent = false;
body: unknown = null;
constructor() {
FakeXHR.last = this;
}
@@ -53,8 +55,9 @@ class FakeXHR {
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
send() {
send(body?: unknown) {
this.sent = true;
this.body = body ?? null;
}
abort() {
this.onabort?.();
@@ -384,3 +387,26 @@ describe("the debug session a tab records into", () => {
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
});
});
// P1 — the upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `require_human` resolve first. A revert to FormData here would silently re-open that, and the
// backend's own test only sees the shape the client actually sends.
describe("uploadDownload's wire shape", () => {
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
const x = FakeXHR.last;
expect(x.body).toBe(file);
expect(x.body).not.toBeInstanceOf(FormData);
expect(x.headers["X-Upload-Filename"]).toBe(encodeURIComponent("nyaralás videó.mp4"));
expect(x.headers["X-Upload-Display-Name"]).toBe(encodeURIComponent("Nyaralás 2026"));
// Content-Type is the browser's to set from the File — an explicit one here would be a guess.
expect(x.headers["Content-Type"]).toBeUndefined();
});
it("omits the display-name header when there is no display name", () => {
void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
});
});