Files
HealthLog/src/lib/__tests__/safe-json-bounded-read.test.ts
T
Marc-André Bombeck 2139b49c47 fix(http): stop reading request bodies of up to 512 MB before authentication
Next reads the body of every non-GET request the proxy matches into
memory before the route runs. The ceiling was 512 MB so the Apple Health
importer could receive whole archives, which let any anonymous POST to any
API path make the server hold that much.

The ceiling is now 1 MB (proxyClientMaxBodySize). The fifteen routes whose
real bodies are larger are left out of the proxy matcher and read their
own body after authenticating; apiHandler gives them the proxy's refusals
(worker-only, demo mode), security headers and a CSP. safeJson now counts
bytes while it reads and stops at its cap instead of reading everything
first, the CSV and dose-history imports do the same, and a multipart
backup upload is counted while it is read. A test compiles the matcher the way
Next does and checks it skips exactly the listed routes and every route
with a larger body is listed.

Local lab OCR runs its WebAssembly engine in a worker served from
/tesseract/. Those responses, and the /labs and /documents pages that start
the OCR, now carry 'wasm-unsafe-eval' in script-src; no other route does.
2026-09-26 16:31:03 +02:00

77 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `safeJson`'s `maxBytes` bounds the READ, not just the parse.
*
* It used to call `request.text()` and compare afterwards, so the whole body
* was held before the cap was consulted. That was tolerable while the proxy
* truncated every body at a fixed ceiling; the large-upload routes no longer
* pass through the proxy, so the cap has to hold on its own — including for a
* body that declares no length.
*/
import { describe, expect, it } from "vitest";
import { readBodyText, safeJson } from "@/lib/api-response";
/** An endless body of 1 KB chunks that counts how many were pulled. */
function endless(): {
stream: ReadableStream<Uint8Array>;
pulled: () => number;
} {
let pulls = 0;
const chunk = new TextEncoder().encode("x".repeat(1024));
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
});
return { stream, pulled: () => pulls };
}
function jsonRequest(
body: BodyInit,
headers: Record<string, string> = {},
): Request {
return new Request("http://localhost/api/x", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body,
// Required by undici for a stream body.
duplex: "half",
} as RequestInit);
}
describe("bounded body read", () => {
it("stops a body without a declared length at the cap", async () => {
const { stream, pulled } = endless();
const res = await safeJson(jsonRequest(stream), { maxBytes: 16 * 1024 });
expect(res.error?.status).toBe(413);
expect(pulled()).toBeLessThan(40);
});
it("refuses a declared oversize body before reading any of it", async () => {
const { stream, pulled } = endless();
const read = await readBodyText(
jsonRequest(stream, { "content-length": String(50 * 1024 * 1024) }),
1024 * 1024,
);
expect(read.tooLarge).toBe(true);
// A pull may be scheduled when the stream is constructed; none after.
expect(pulled()).toBeLessThanOrEqual(1);
});
it("parses a body under the cap, multibyte text intact", async () => {
const res = await safeJson<{ note: string }>(
jsonRequest(JSON.stringify({ note: "Blutdruck gemessen – ok" })),
{ maxBytes: 1024 },
);
expect(res.data).toEqual({ note: "Blutdruck gemessen – ok" });
});
it("counts bytes, not characters", async () => {
// 600 three-byte characters: 600 chars, 1800 bytes.
const body = JSON.stringify({ s: "€".repeat(600) });
const res = await safeJson(jsonRequest(body), { maxBytes: 1024 });
expect(res.error?.status).toBe(413);
});
});