From 2139b49c47550abd4355c3a02ef9ae57f706baca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:31:03 +0200 Subject: [PATCH] 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. --- next.config.ts | 21 +-- .../api-handler-bypass-route.test.ts | 71 ++++++++ src/__tests__/proxy-body-limit.test.ts | 164 ++++++++++++++++++ src/__tests__/proxy-local-ocr-csp.test.ts | 65 +++++++ src/app/api/admin/backups/upload/route.ts | 13 +- src/app/api/import/csv/route.ts | 16 +- .../intake/dose-history-import/route.ts | 16 +- .../__tests__/safe-json-bounded-read.test.ts | 76 ++++++++ src/lib/api-handler.ts | 19 ++ src/lib/api-response.ts | 75 ++++++-- src/lib/http/proxy-bypass-routes.ts | 122 +++++++++++++ src/proxy.ts | 45 +++-- 12 files changed, 647 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/api-handler-bypass-route.test.ts create mode 100644 src/__tests__/proxy-body-limit.test.ts create mode 100644 src/__tests__/proxy-local-ocr-csp.test.ts create mode 100644 src/lib/__tests__/safe-json-bounded-read.test.ts create mode 100644 src/lib/http/proxy-bypass-routes.ts diff --git a/next.config.ts b/next.config.ts index 3e3a554fa..2edfcfba3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -295,18 +295,19 @@ const nextConfig: NextConfig = { outputFileTracingExcludes: { "*": ["./next.config.ts"], }, - // Next 16 caps the request body that passes THROUGH middleware (our - // `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies - // before the route handler sees them. The Apple Health `export.zip` importer - // (`/api/import/apple-health-export`) streams multi-MB archives to disk; the - // 10MB cap truncated them so the ZIP end-of-central-directory was lost and - // the parser failed (GitHub #281). Raise the ceiling so real exports pass - // intact. The value is a ceiling, not a buffer — only the actual upload size - // is held — so this is safe; very large exports also want a matching - // reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md). + // Next reads the body of every non-GET request the proxy (`src/proxy.ts`) + // matches into memory, so both the proxy and the route can read it, and it + // does so BEFORE the route runs — before any authentication. Everything past + // this ceiling is dropped, not refused. It used to be 512 MB so the Apple + // Health importer could receive whole archives (GitHub #281), which let any + // anonymous POST make the server hold that much. It is 1 MB now: the routes + // whose real bodies are larger are left out of the proxy matcher and read + // their own body after authenticating (`src/lib/http/proxy-bypass-routes.ts`), + // and every other route's own cap is at or below this. Large exports still + // want a matching reverse-proxy body limit (docs/self-hosting/reverse-proxy.md). experimental: { optimizePackageImports: ["recharts", "lucide-react"], - middlewareClientMaxBodySize: "512mb", + proxyClientMaxBodySize: "1mb", }, }; diff --git a/src/__tests__/api-handler-bypass-route.test.ts b/src/__tests__/api-handler-bypass-route.test.ts new file mode 100644 index 000000000..5c5410cce --- /dev/null +++ b/src/__tests__/api-handler-bypass-route.test.ts @@ -0,0 +1,71 @@ +/** + * The large-upload routes are left out of the proxy matcher, so `apiHandler` + * performs the proxy's duties for them: the worker-only and demo-mode + * refusals, the transport security headers and a CSP. Every other route still + * gets those from the proxy and is left untouched here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const runWeb = vi.hoisted(() => ({ value: true })); +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => runWeb.value, + shouldRunWorker: () => true, + getProcessType: () => "all", +})); + +import { apiHandler } from "@/lib/api-handler"; + +afterEach(() => { + vi.unstubAllEnvs(); + runWeb.value = true; +}); + +function call(path: string, method = "POST") { + const handler = vi.fn(async (_req: NextRequest) => + NextResponse.json({ data: "ok" }), + ); + const wrapped = apiHandler(handler); + return { + handler, + res: wrapped(new NextRequest(`http://localhost${path}`, { method })), + }; +} + +describe("apiHandler on a route the proxy does not see", () => { + it("adds the proxy's security headers and a CSP", async () => { + const { res } = call("/api/measurements/batch"); + const r = await res; + expect(r.status).toBe(200); + expect(r.headers.get("x-content-type-options")).toBe("nosniff"); + expect(r.headers.get("x-frame-options")).toBe("DENY"); + expect(r.headers.get("content-security-policy")).toContain( + "default-src 'none'", + ); + expect(r.headers.get("x-request-id")).toBeTruthy(); + }); + + it("refuses a mutation on a demo instance before the handler runs", async () => { + vi.stubEnv("DEMO_MODE", "true"); + const { res, handler } = call("/api/import/csv"); + const r = await res; + expect(r.status).toBe(403); + expect(handler).not.toHaveBeenCalled(); + expect(r.headers.get("x-frame-options")).toBe("DENY"); + }); + + it("refuses in a worker-only container", async () => { + runWeb.value = false; + const { res, handler } = call("/api/admin/backups/upload"); + expect((await res).status).toBe(503); + expect(handler).not.toHaveBeenCalled(); + }); + + it("leaves a proxied route to the proxy", async () => { + vi.stubEnv("DEMO_MODE", "true"); + const { res, handler } = call("/api/auth/me/timezone", "PUT"); + const r = await res; + expect(handler).toHaveBeenCalled(); + expect(r.headers.get("content-security-policy")).toBeNull(); + }); +}); diff --git a/src/__tests__/proxy-body-limit.test.ts b/src/__tests__/proxy-body-limit.test.ts new file mode 100644 index 000000000..a14e33dbe --- /dev/null +++ b/src/__tests__/proxy-body-limit.test.ts @@ -0,0 +1,164 @@ +/** + * The proxy's body ceiling, and the routes that stay out of the proxy so their + * larger bodies are never read before authentication. + * + * Next reads the body of every non-GET request the proxy matcher covers into + * memory before the route runs. At 512 MB that let any anonymous POST make the + * server hold half a gigabyte. The ceiling is 1 MB; the routes whose bodies are + * legitimately larger are excluded from the matcher and bound their own body + * after the caller is known. Three things have to stay true together, and this + * pins each: + * + * 1. the ceiling in next.config.ts is 1 MB; + * 2. the matcher, compiled exactly as Next compiles it, skips precisely the + * bypass list and still covers every other API route (and the tesseract + * worker, which needs its CSP); + * 3. every route that reads a body larger than the ceiling is on the list. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { + PROXY_BYPASS_ROUTES, + isProxyBypassRoute, +} from "@/lib/http/proxy-bypass-routes"; + +vi.mock("@/lib/process-type", () => ({ shouldRunWeb: () => true })); + +const ROOT = process.cwd(); +const ONE_MB = 1024 * 1024; + +async function compiledMatcher(): Promise { + const { config } = await import("../proxy"); + // Next's own compiler for `config.matcher`, so the test sees the regex the + // server will actually run. Not part of Next's typed surface. + const analysis = + (await import("next/dist/build/analysis/get-page-static-info.js")) as unknown as { + getMiddlewareMatchers: ( + m: unknown, + c: Record, + ) => Array<{ regexp: string }>; + }; + const { getMiddlewareMatchers } = analysis; + const [m] = getMiddlewareMatchers(config.matcher, {}); + return new RegExp(m.regexp); +} + +/** Every API route path, with dynamic segments filled in. */ +function apiRoutePaths(): string[] { + const appDir = join(ROOT, "src", "app"); + return walkSourceFiles(appDir, { floor: 500 }) + .filter((p) => p.startsWith("api/") && p.endsWith("/route.ts")) + .filter((p) => !p.includes("__tests__")) + .map((p) => + ( + "/" + p.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x") + ).replace(/\/\([^)]+\)/g, ""), + ) + .sort(); +} + +describe("proxy body ceiling", () => { + it("is 1 MB, under the current option name", () => { + const src = readFileSync(join(ROOT, "next.config.ts"), "utf8"); + expect(src).toMatch(/proxyClientMaxBodySize:\s*"1mb"/); + expect(src).not.toMatch(/middlewareClientMaxBodySize/); + }); +}); + +describe("proxy matcher", () => { + it("skips exactly the bypass routes and covers every other API route", async () => { + const re = await compiledMatcher(); + const routes = apiRoutePaths(); + expect(routes.length).toBeGreaterThan(300); + + for (const path of PROXY_BYPASS_ROUTES) { + expect(routes, `${path} is not a route`).toContain(path); + expect(re.test(path), `${path} still passes the proxy`).toBe(false); + expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe( + false, + ); + } + const covered = routes.filter((p) => !isProxyBypassRoute(p)); + const escaped = covered.filter((p) => !re.test(p)); + expect(escaped).toEqual([]); + }); + + it("still covers pages, the tesseract worker and sub-paths of bypass routes", async () => { + const re = await compiledMatcher(); + for (const path of [ + "/", + "/dashboard", + "/labs", + "/documents", + "/tesseract/worker.min.js", + "/api/auth/login", + "/api/user/avatar/abc", + "/api/documents/inbound/abc/extract", + "/api/import/csv/preview", + ]) { + expect(re.test(path), path).toBe(true); + } + }); +}); + +/** Byte caps a route file declares, from `maxBytes:` literals and `*_BYTES` constants. */ +function declaredCaps(src: string): number[] { + const consts = new Map(); + for (const m of src.matchAll( + /const\s+([A-Z0-9_]*BYTES)\s*=\s*([\d.\s*_]+);/g, + )) { + const value = Function(`return (${m[2].replace(/_/g, "")})`)() as number; + consts.set(m[1], value); + } + const caps: number[] = [...consts.values()]; + for (const m of src.matchAll(/maxBytes:\s*([\d\s*_]+)[,\s}]/g)) { + caps.push(Function(`return (${m[1].replace(/_/g, "")})`)() as number); + } + return caps; +} + +describe("the bypass list is complete", () => { + const appDir = join(ROOT, "src", "app"); + const files = walkSourceFiles(appDir, { floor: 500 }).filter( + (p) => + p.startsWith("api/") && + p.endsWith("/route.ts") && + !p.includes("__tests__"), + ); + const pathOf = (rel: string) => + "/" + rel.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x"); + + it("lists every route whose declared body cap exceeds the ceiling", () => { + const over = files + .filter((rel) => + declaredCaps(readFileSync(join(appDir, rel), "utf8")).some( + (n) => n > ONE_MB, + ), + ) + .map(pathOf); + expect(over.length).toBeGreaterThan(5); + expect(over.filter((p) => !isProxyBypassRoute(p))).toEqual([]); + }); + + it("lists every route that streams or multipart-parses its body", () => { + // The OAuth forms are tiny url-encoded bodies with their own 16 KB cap. + const SMALL_FORMS = new Set([ + "/api/mcp/oauth/token", + "/api/mcp/oauth/register", + "/api/mcp/oauth/authorize", + ]); + const streaming = files + .filter((rel) => + /readBoundedBody\(|Readable\.fromWeb\(|\.formData\(\)|request\.body\b/.test( + readFileSync(join(appDir, rel), "utf8"), + ), + ) + .map(pathOf) + .filter((p) => !SMALL_FORMS.has(p)); + expect(streaming.length).toBeGreaterThan(3); + expect(streaming.filter((p) => !isProxyBypassRoute(p))).toEqual([]); + }); +}); diff --git a/src/__tests__/proxy-local-ocr-csp.test.ts b/src/__tests__/proxy-local-ocr-csp.test.ts new file mode 100644 index 000000000..33b28ac7e --- /dev/null +++ b/src/__tests__/proxy-local-ocr-csp.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker + * served from `/tesseract/`. Compiling WebAssembly needs `'wasm-unsafe-eval'` + * in `script-src`, and a worker takes its CSP from its own script's response. + * The grant is scoped the way the AI-host and Withings carve-outs are: the + * worker files and the two pages that start OCR, and nothing else. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function scriptSrc(pathname: string): string { + const res = proxy( + new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }), + ); + const csp = res.headers.get("content-security-policy") ?? ""; + return csp.split(";").find((d) => d.trim().startsWith("script-src")) ?? ""; +} + +describe("'wasm-unsafe-eval' for local OCR", () => { + it.each([ + "/tesseract/worker.min.js", + "/tesseract/tesseract-core-simd-lstm.wasm.js", + "/labs", + "/documents", + ])("is granted on %s", (path) => { + expect(scriptSrc(path)).toContain("'wasm-unsafe-eval'"); + }); + + it.each([ + "/dashboard", + "/", + "/labs/some-biomarker", + "/settings/labs", + "/settings/ai", + "/api/labs/ocr/commit", + "/tesseractish", + ])("is not granted on %s", (path) => { + const src = scriptSrc(path); + expect(src).toContain("script-src"); + expect(src).not.toContain("wasm-unsafe-eval"); + }); + + it("never widens script-src to plain eval", () => { + expect(scriptSrc("/tesseract/worker.min.js")).not.toContain( + "'unsafe-eval'", + ); + }); +}); diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 50e74d36f..293676478 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -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( diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index bbd46810c..18ea69cab 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -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) { diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index df904080a..3949c3479 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -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) { diff --git a/src/lib/__tests__/safe-json-bounded-read.test.ts b/src/lib/__tests__/safe-json-bounded-read.test.ts new file mode 100644 index 000000000..9e2f2f9ee --- /dev/null +++ b/src/lib/__tests__/safe-json-bounded-read.test.ts @@ -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; + pulled: () => number; +} { + let pulls = 0; + const chunk = new TextEncoder().encode("x".repeat(1024)); + const stream = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + }); + return { stream, pulled: () => pulls }; +} + +function jsonRequest( + body: BodyInit, + headers: Record = {}, +): 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); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 236d080ae..37c0c1863 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 Promise>( } })(); + // 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 Promise>( } 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 diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 860685e02..b54276608 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -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( request: Request, @@ -312,19 +357,19 @@ export async function safeJson( return { error: apiError("Content-Type must be application/json", 415) }; } if (opts?.maxBytes !== undefined) { - let raw: string; + let read: Awaited>; 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) }; diff --git a/src/lib/http/proxy-bypass-routes.ts b/src/lib/http/proxy-bypass-routes.ts new file mode 100644 index 000000000..83e0e1466 --- /dev/null +++ b/src/lib/http/proxy-bypass-routes.ts @@ -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; +} diff --git a/src/proxy.ts b/src/proxy.ts index 6c7f711ac..b514ab5a4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,6 +5,7 @@ import { retiredRouteEnvelope, RETIRED_ROUTE_STATUS, } from "@/lib/http/retired-routes"; +import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). @@ -289,21 +290,7 @@ function generateNonce(): string { * routes that only make sense for a response with a body to protect. */ function applyBaselineSecurityHeaders(response: NextResponse): NextResponse { - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set( - "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", - ); - response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); - response.headers.set("X-Permitted-Cross-Domain-Policies", "none"); - if (process.env.NODE_ENV !== "development") { - response.headers.set( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains; preload", - ); - } + setBaselineSecurityHeaders(response.headers); return response; } @@ -647,6 +634,21 @@ export function proxy(request: NextRequest) { pathname.startsWith("/settings/integrations/strava") || pathname.startsWith("/api/strava/"); const stravaConnectSrc = isStravaRoute ? " https://www.strava.com" : ""; + // Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker + // served from `/tesseract/`. A worker runs under the CSP delivered with its + // own script, so compiling the WebAssembly needs `'wasm-unsafe-eval'` on + // that response. It is also granted on the two pages that start the OCR + // (`/labs` for lab scans, `/documents` for document text), in case the + // engine compiles on the page. Nowhere else: `'wasm-unsafe-eval'` allows + // compiling WebAssembly, not `eval`, but a page that never runs the engine + // has no reason to carry it. (A client-side navigation keeps the CSP of + // the page first loaded, which is why the worker script's own response is + // the grant that matters.) + const isLocalOcrSurface = + pathname.startsWith("/tesseract/") || + pathname === "/labs" || + pathname === "/documents"; + const wasmScriptSrc = isLocalOcrSurface ? " 'wasm-unsafe-eval'" : ""; // v1.5.5 — Gravatar host removed from `img-src`. The /me payload // used to return `gravatarUrl: https://www.gravatar.com/avatar/`, // which leaked the email digest to Automattic on every authenticated @@ -682,7 +684,7 @@ export function proxy(request: NextRequest) { // is barred by the record-fence guard. isDev ? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; font-src 'self';` - : `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`; + : `default-src 'self'; script-src 'self' 'nonce-${nonce}'${wasmScriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`; // The MCP OAuth authorization endpoint sets its own CSP. Its consent form // posts to this origin and the server then redirects to the connecting // client's `redirect_uri`; Chromium applies `form-action` to that redirect, @@ -715,7 +717,14 @@ export function proxy(request: NextRequest) { export const config = { matcher: [ - // Apply to all routes except static files, Next.js internals, SW, and manifest - "/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)", + // Apply to all routes except static files, Next.js internals, SW, and + // manifest — and except the large-upload API routes listed in + // `src/lib/http/proxy-bypass-routes.ts`. Next reads a request body into + // memory for the proxy before the route runs, so a route whose body may + // exceed `proxyClientMaxBodySize` (1 MB, next.config.ts) is left out of + // the matcher and `apiHandler` performs the proxy's duties for it. The + // alternation must stay equal to that list; `proxy-body-limit.test.ts` + // compiles this exactly as Next does and checks. + "/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)", ], };