import { QrRequestError } from "@/lib/qr/type-module"; /** * Kommt diese Anfrage von der eigenen Oberfläche? * * Zweite Verteidigungslinie neben `SameSite=Lax` am Sitzungs-Cookie. Das * OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht * genügt — es wirkt auf der Ebene der registrierbaren Domain, nicht des * Ursprungs. * * Verglichen wird gegen den Host der Anfrage, nicht gegen `BASE_URL`: Die * Anwendung ist absichtlich unter mehreren Domains erreichbar. * * Fehlt der `Origin`-Header, gilt die Anfrage als eigen: Browser setzen ihn * bei fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar * nicht. Ein Abweisen träfe nur die Falschen. */ export function isSameOrigin(headers: Headers): boolean { const origin = headers.get("origin"); if (!origin) return true; let herkunft: URL; try { herkunft = new URL(origin); } catch { return false; } // Das Schema kommt aus `BASE_URL`, nicht aus der Anfrage: Es ist die eine // Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. Ohne diesen // Vergleich gälte `http://example.com` als eigen, obwohl die Anwendung nur // unter `https://example.com` läuft. const erwartetesSchema = (process.env.BASE_URL ?? "").startsWith("http://") ? "http:" : "https:"; if (herkunft.protocol !== erwartetesSchema) return false; const host = headers.get("x-forwarded-host") ?? headers.get("host"); return Boolean(host) && herkunft.host === host; } export function assertSameOrigin(headers: Headers): void { if (!isSameOrigin(headers)) { throw new QrRequestError( "Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.", 403, ); } } export function jsonError(message: string, status = 400): Response { return Response.json({ error: message }, { status }); } /** * Erkennt Fehler, deren Meldung an den Aufrufer gehen darf. * * Bewusst über eine Eigenschaft statt über `instanceof`: die Fehlerklassen * stehen in einem Modul, das sowohl im Server- als auch im Client-Bündel * landet. Dann existieren zwei Klassenidentitäten, und ein * `instanceof`-Vergleich würde eine saubere 400er-Antwort stillschweigend zu * einem 500er machen. */ function asUserFacing( error: unknown, ): { message: string; status: number } | null { if ( typeof error !== "object" || error === null || (error as { userFacing?: unknown }).userFacing !== true ) { return null; } const message = (error as { message?: unknown }).message; if (typeof message !== "string") return null; const status = (error as { status?: unknown }).status; return { message, status: typeof status === "number" ? status : 400 }; } /** * Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung. * Unerwartete Fehler werden protokolliert, nach außen aber generisch * gemeldet. */ export function errorResponse(error: unknown): Response { const userFacing = asUserFacing(error); if (userFacing) { return jsonError(userFacing.message, userFacing.status); } console.error("Unerwarteter Fehler in einer API-Route:", error); return jsonError( "Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.", 500, ); } /** Erste Fehlermeldung eines fehlgeschlagenen Zod-Parsers. */ export function firstZodMessage(error: { issues: ReadonlyArray<{ message: string }>; }): string { return error.issues[0]?.message ?? "Die Anfrage ist ungültig."; }