mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
6819ce70c6
commit
2139b49c47
12 files changed
+647
-56
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user