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

+10 -3
View File
@@ -66,9 +66,9 @@ export const dynamic = "force-dynamic";
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/**
* Cap on a file sent as the raw request body, compressed or not. The same
* ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
* `next.config.ts`), beyond which the body would arrive truncated. A
* Cap on a file sent as the raw request body, compressed or not, counted
* while the body streams. The route is left out of the proxy matcher
* (`src/lib/http/proxy-bypass-routes.ts`), so this is the only ceiling. A
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
* JSON and 64 MB compressed, so large files go up compressed.
*/
@@ -169,6 +169,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Cheap pre-flight on the declared content length. The byte count while
// reading is the hard limit; this just rejects obvious abuse early.
const contentLength = Number(request.headers.get("content-length") ?? 0);
// The multipart parser holds the whole form in memory and counts nothing,
// so a form must declare its length for the check below to bound it. The
// raw-body path counts while it streams and needs no declaration.
if (multipart && !(contentLength > 0)) {
await denied("content_length_missing");
return apiError("A multipart upload must declare its Content-Length", 411);
}
if (contentLength > limit) {
await denied("content_length_exceeded", { contentLength });
return apiError(
+11 -5
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
readBodyText,
} from "@/lib/api-response";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -68,15 +73,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (Number.isFinite(declaredLength) && declaredLength > MAX_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
// Counted while it is read, so a body without a Content-Length cannot run
// past the cap before the check.
let text: string;
try {
text = await request.text();
const read = await readBodyText(request, MAX_CSV_BYTES);
if (read.tooLarge) return apiError("CSV exceeds the 16 MB limit", 413);
text = read.text;
} catch {
return apiError("Could not read the request body", 400);
}
if (Buffer.byteLength(text, "utf8") > MAX_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
const parsed = parseCsvMeasurements(text);
if (parsed.fatal) {
@@ -30,7 +30,12 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
readBodyText,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
@@ -133,15 +138,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
// Counted while it is read, so a body without a Content-Length cannot run
// past the cap before the check.
let text: string;
try {
text = await request.text();
const read = await readBodyText(request, MAX_BYTES);
if (read.tooLarge) return apiError("File exceeds the 16 MB limit", 413);
text = read.text;
} catch {
return apiError("Could not read the request body", 400);
}
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
if (parsed.fatal) {