import { LOCALE_HEADER, normaliseLocale, translate } from "./i18n-dict"; /** * Small server-side guards shared by the API routes. * * Freezmo is a self-hosted app with no accounts, so the realistic threats are * (a) a hostile page in the user's browser poking the local server, (b) huge * bodies exhausting memory, and (c) odd values ending up in outbound headers. */ /** The language this request asked for (browsers send x-freezmo-locale). */ export function requestLocale(request: Request) { return normaliseLocale(request.headers.get(LOCALE_HEADER)); } function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; } /** Rejects requests whose body is larger than `maxBytes` (Content-Length check). */ export function rejectOversizedBody(request: Request, maxBytes: number): Response | null { const header = request.headers.get("content-length"); if (!header) return null; const length = Number.parseInt(header, 10); if (Number.isFinite(length) && length > maxBytes) { return Response.json( { error: translate(requestLocale(request), "server.bodyTooLarge", { size: formatBytes(length), limit: formatBytes(maxBytes), }), kind: "too_large", }, { status: 413 }, ); } return null; } /** * Same-origin check for state-changing requests. * * There is no cookie-based auth here, so CSRF can't steal a session — but a * malicious website could still make the user's browser fire requests at a * LAN-hosted Freezmo (and burn their rate limit or, in `server` auth mode, * their credits). Browsers always send `Origin` on cross-origin POSTs and * cannot forge it, so a mismatch is a reliable signal. Requests without an * `Origin` (curl, scripts, native apps) are allowed. */ export function rejectCrossOrigin(request: Request): Response | null { const origin = request.headers.get("origin"); if (!origin) return null; let originHost: string; try { originHost = new URL(origin).host; } catch { return Response.json( { error: translate(requestLocale(request), "server.invalidOrigin"), kind: "forbidden" }, { status: 403 }, ); } const host = request.headers.get("host"); if (host && originHost === host) return null; // Next dev/prod behind a proxy may rewrite Host; trust x-forwarded-host too. const forwardedHost = request.headers.get("x-forwarded-host"); if (forwardedHost && originHost === forwardedHost) return null; // Allow the loopback pair (localhost vs 127.0.0.1) for convenience. const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]); const bare = (value: string) => value.replace(/:\d+$/, ""); if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null; return Response.json( { error: translate(requestLocale(request), "server.crossOrigin"), kind: "forbidden" }, { status: 403 }, ); } /** * API keys are pasted by humans, so clean them before they go into a header: * trim, drop control characters/whitespace (header injection), cap the length. */ export function sanitiseApiKey(value: string | null | undefined): string { if (!value) return ""; return value .replace(/[\u0000-\u001F\u007F-\u009F\s]/g, "") .slice(0, 512); }