Files
HealthLog/src/__tests__/api-handler-bypass-route.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

72 lines
2.4 KiB
TypeScript

/**
* 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();
});
});