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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:31:03 +02:00
1 parent 6819ce70c6
commit 2139b49c47
12 files changed
+647 -56

No files matched your search

@@ -0,0 +1,76 @@
/**
* `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);
});
});
+19
View File
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { AccountGrant, User } from "@/generated/prisma/client";
import {
applyBypassRouteHeaders,
bypassRouteRefusal,
isProxyBypassRoute,
} from "@/lib/http/proxy-bypass-routes";
import { prisma } from "@/lib/db";
import { WideEventBuilder } from "./logging/event-builder";
import { annotate, eventStorage, getEvent } from "./logging/context";
@@ -200,6 +205,19 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
})();
// A route the proxy matcher leaves out (large uploads, see
// `proxy-bypass-routes.ts`) gets the proxy's refusals and headers here.
const isBypassRoute = isProxyBypassRoute(url.pathname);
if (isBypassRoute) {
const refusal = bypassRouteRefusal(
safeRequestProp(request, (r) => r.method, "GET"),
);
if (refusal) {
applyBypassRouteHeaders(refusal.headers);
return refusal;
}
}
const evt = new WideEventBuilder("http");
// Propagate x-request-id if present
@@ -330,6 +348,7 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
if (isBypassRoute) applyBypassRouteHeaders(nr.headers);
// v1.37.0 — echo the record context this response was actually served
// under, when one was decided. The value comes from the wide event and
// from nowhere else: the fence stamps it on every call it makes, so
+60 -15
View File
@@ -287,21 +287,66 @@ export function apiError(message: string, status = 400, meta?: ErrorMeta) {
return buildJsonErrorResponse({ data: null, error: message }, status, meta);
}
/**
* Read a request body as text, counting BYTES as they arrive and stopping at
* `maxBytes`. A body that declares a larger `Content-Length` is refused before
* a byte is read; one that does not (chunked) is cancelled the moment it
* passes the cap. `request.text()` has no such bound — it holds whatever the
* client sends — which mattered little while the proxy's body clone truncated
* everything at a fixed ceiling, and matters for the upload routes that no
* longer pass through the proxy.
*/
export async function readBodyText(
request: Request,
maxBytes: number,
): Promise<{ text: string; tooLarge?: never } | { tooLarge: true }> {
const declared = Number(request.headers.get("content-length") ?? "");
if (Number.isFinite(declared) && declared > maxBytes) {
return { tooLarge: true };
}
const body = request.body;
if (!body) return { text: "" };
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
void reader.cancel().catch(() => {});
return { tooLarge: true };
}
chunks.push(value);
}
} finally {
try {
reader.releaseLock();
} catch {
// Already released by the cancel above.
}
}
const joined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.byteLength;
}
return { text: new TextDecoder().decode(joined) };
}
/**
* Safely parse JSON body from a request.
* Returns the parsed body or a 400 error response if parsing fails.
*
* `opts.maxBytes` opts a route into a hard body-size cap. The check
* runs against the raw text length rather than the parsed object, so an
* over-limit payload returns 413 before reaching `JSON.parse` and never
* builds an object graph. (`request.text()` still buffers the raw body
* into a string, so the cap bounds the parse/object cost, not the
* initial read — keep caps comfortably above the largest legitimate
* payload.) Single-record routes can pass a tight cap; batch routes
* that legitimately accept large payloads pass a larger one. When
* `maxBytes` is omitted the route inherits the Next.js runtime default —
* pre-existing behaviour, no regression risk for callers that haven't
* adopted the parameter.
* `opts.maxBytes` opts a route into a hard body-size cap, enforced WHILE the
* body is read (`readBodyText`): an over-limit payload returns 413 without the
* whole body ever being held, and never reaches `JSON.parse`. Single-record
* routes can pass a tight cap; batch routes that legitimately accept large
* payloads pass a larger one. When `maxBytes` is omitted the body is bounded
* only by the proxy's 1 MB clone ceiling (`proxyClientMaxBodySize`), so a
* route left out of the proxy matcher must pass one.
*/
export async function safeJson<T = unknown>(
request: Request,
@@ -312,19 +357,19 @@ export async function safeJson<T = unknown>(
return { error: apiError("Content-Type must be application/json", 415) };
}
if (opts?.maxBytes !== undefined) {
let raw: string;
let read: Awaited<ReturnType<typeof readBodyText>>;
try {
raw = await request.text();
read = await readBodyText(request, opts.maxBytes);
} catch {
return { error: apiError("Invalid request body", 400) };
}
if (raw.length > opts.maxBytes) {
if (read.tooLarge) {
return {
error: apiError(`Request body exceeds ${opts.maxBytes} bytes`, 413),
};
}
try {
const data = JSON.parse(raw) as T;
const data = JSON.parse(read.text) as T;
return { data };
} catch {
return { error: apiError("Invalid JSON body", 400) };
+122
View File
@@ -0,0 +1,122 @@
/**
* Upload routes that do NOT pass through `src/proxy.ts`, and the edge duties
* `apiHandler` takes over for them.
*
* WHY. Next clones the body of every non-GET request the proxy matcher covers
* so both the proxy and the route can read it, and it reads that clone off the
* socket into memory up to `experimental.proxyClientMaxBodySize` before the
* route — and therefore authentication — runs at all. The ceiling was 512 MB
* so the Apple Health importer could receive whole archives, which meant any
* anonymous POST to any API path could make the server hold half a gigabyte
* (twice, one copy per branch of the clone). The ceiling is now 1 MB, and the
* routes whose legitimate bodies are larger are taken out of the matcher: they
* stream or bound their own body after authenticating, so nothing reads their
* body before the caller is known.
*
* WHAT THE PROXY DID FOR THEM, AND WHO DOES IT NOW. For an `/api/*` route the
* proxy refuses traffic in a worker-only container, refuses every mutation on
* a demo instance (none of these routes is on the demo allowlist), sets the
* transport security headers and a CSP, and echoes `x-request-id`. `apiHandler`
* does all of that for the routes listed here (`bypassRouteRefusal`,
* `applyBypassRouteHeaders`); it already echoes `x-request-id` for every route.
* The legacy redirects, the retired-route 410s and the page session gate never
* applied to these paths.
*
* ONE LIST, TWO READERS. The matcher in `src/proxy.ts` must be a literal Next
* can analyse at build time, so it cannot import this list; a test compiles the
* matcher exactly as Next does and checks it excludes every path here and
* nothing else under `/api`.
*/
import { shouldRunWeb } from "@/lib/process-type";
export const PROXY_BYPASS_ROUTES: readonly string[] = [
// Streamed archive uploads, up to 1.5 GB.
"/api/import/apple-health-export",
"/api/admin/import-apple-health-export",
// Backup file upload, up to 512 MB raw.
"/api/admin/backups/upload",
// Document vault upload, admin-tunable per-file cap.
"/api/documents/inbound",
// Bulk JSON / CSV imports, 16 MB.
"/api/import",
"/api/import/csv",
"/api/medications/intake/dose-history-import",
// Lab scan upload, 12 MB.
"/api/labs/ocr/extract",
// Avatar image, 2 MB.
"/api/user/avatar",
// Native-client batches: measurements 4 MB, workouts 5 MB, the rest 2 MB.
"/api/measurements/batch",
"/api/workouts/batch",
"/api/insights/ecg",
"/api/mood-entries/bulk",
"/api/medications/intake/bulk",
"/api/cycle/day-logs/bulk",
];
export function isProxyBypassRoute(pathname: string): boolean {
const normalised =
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
return PROXY_BYPASS_ROUTES.includes(normalised);
}
/**
* The transport security headers the proxy puts on every response. Shared so
* the proxy and the bypass routes cannot drift apart.
*/
export function setBaselineSecurityHeaders(headers: Headers): void {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Frame-Options", "DENY");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
headers.set("Cross-Origin-Opener-Policy", "same-origin");
headers.set("X-Permitted-Cross-Domain-Policies", "none");
if (process.env.NODE_ENV !== "development") {
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
);
}
}
/**
* The CSP for a bypass route's response. These answer JSON, never a page, so
* the response may load nothing and be framed by nobody.
*/
export const BYPASS_ROUTE_CSP =
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
export function applyBypassRouteHeaders(headers: Headers): void {
setBaselineSecurityHeaders(headers);
headers.set("Content-Security-Policy", BYPASS_ROUTE_CSP);
}
/**
* The refusals the proxy would have answered with, in the same shape.
* Returns null when the request may proceed.
*/
export function bypassRouteRefusal(method: string): Response | null {
if (!shouldRunWeb()) {
return Response.json(
{
data: null,
error:
"This container runs the worker only — point HTTP at the web service.",
},
{ status: 503, headers: { "X-HealthLog-Process-Type": "worker" } },
);
}
const m = method.toUpperCase();
const isMutation = m !== "GET" && m !== "HEAD" && m !== "OPTIONS";
if (process.env.DEMO_MODE === "true" && isMutation) {
return Response.json(
{
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true },
},
{ status: 403 },
);
}
return null;
}