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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,16 +187,23 @@ export const downloadsApi = {
|
||||
// Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload
|
||||
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||
// download-only). Returns the already-`done` job the ingest creates.
|
||||
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
|
||||
// a DECLARED body, which FastAPI reads and spools BEFORE the route's `require_human` dependency
|
||||
// runs — so with the old shape an unauthenticated caller could make the server write the whole
|
||||
// per-file allowance to temp (P1). Headers are latin-1 on the wire, hence `encodeURIComponent`;
|
||||
// the server percent-decodes both. Content-Type is left to the browser (the File's own type).
|
||||
uploadDownload: (
|
||||
file: File,
|
||||
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.displayName) form.append("display_name", opts.displayName);
|
||||
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
|
||||
};
|
||||
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
|
||||
return uploadReq<DownloadJob>("/api/downloads/upload", file, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
enqueueEdit: (body: {
|
||||
|
||||
Reference in New Issue
Block a user