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

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

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

165 lines
5.7 KiB
TypeScript

/**
* 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<RegExp> {
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<string, unknown>,
) => 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<string, number>();
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([]);
});
});