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)$).*)", ], };