import { getBaseUrl } from "@/lib/url"; /** * Hosts, die fuer diesen Request als "eigener Origin" gelten duerfen. * * Reihenfolge nach Verlaesslichkeit: * 1. X-Forwarded-Host — der oeffentliche Host, den der eigene Reverse-Proxy * (Caddy bzw. Traefik, siehe Caddyfile bzw. k8s/06-ingress.yaml) * weiterreicht. Caddy verwirft einen vom Client mitgeschickten * X-Forwarded-Host per Default; bei Traefik entscheidet darueber * "forwardedHeaders.trustedIPs" des EntryPoints — nur fuer dort * aufgefuehrte Absender werden eingehende X-Forwarded-*-Header * uebernommen, von allen anderen ersetzt. * 2. Host — der Host, den der Browser tatsaechlich adressiert hat. Das ist * die massgebliche Groesse fuer eine Same-Origin-Pruefung und genau der * Vergleich, den Next.js fuer Server Actions intern selbst anstellt. * 3. request.url — nur noch als Rueckfallebene. Next.js rekonstruiert diese * URL aus der internen Bind-Adresse des Servers, NICHT aus dem * Host-Header; sie traegt daher z.B. "localhost:3000", waehrend der * Browser "wabenhain.com" adressiert hat. * 4. NEXT_PUBLIC_SITE_URL (getBaseUrl) — die konfigurierte oeffentliche * Adresse, falls gesetzt. * * Warum der Host-Header hier sicher ist: eine fremde Seite kann bei einem * Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen. Den * Host-Header setzt der Browser aus der Ziel-URL des Requests (also dem * eigenen Host), und eigene Header wie X-Forwarded-Host liessen sich nur * per fetch() setzen, was einen CORS-Preflight ausloest, den dieser * Endpunkt nicht beantwortet. Ein gefaelschter Cross-Site-POST traegt also * weiterhin zwangslaeufig einen fremden Origin und faellt durch. */ function trustedHosts(request: Request): string[] { const hosts: string[] = []; // Bei mehreren Proxies steht der urspruenglich vom Client adressierte Host // als ERSTER Eintrag einer kommaseparierten Kette. const forwardedHost = request.headers.get("x-forwarded-host"); if (forwardedHost) hosts.push(forwardedHost.split(",")[0].trim()); const host = request.headers.get("host"); if (host) hosts.push(host.trim()); try { hosts.push(new URL(request.url).host); } catch { // request.url ist in der Praxis immer parsebar; ein Fehler hier darf die // uebrigen Kandidaten nicht mitreissen. } try { hosts.push(new URL(getBaseUrl()).host); } catch { // Falsch gesetztes NEXT_PUBLIC_SITE_URL darf die Pruefung nicht sprengen. } return hosts.filter(Boolean); } /** * Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler. * * Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt * nur fuer Server Actions ("use server") — eigene Route-Handler wie * /api/checkout oder /api/admin/upload sind normale POST-Endpunkte ohne * diesen automatischen Schutz und muessen ihn sich selbst holen. Ohne diese * Pruefung liesse sich ein state-aendernder POST z.B. per *
als "einfacher" (nicht preflight-pflichtiger) * Cross-Origin-Request faelschen (bekannte JSON-CSRF-Umgehung) — bei * /api/checkout koennte das, sofern der Besuch von einer eingeloggten * Kundschaft ausgeht, Bestellungen unter deren Konto anlegen und * Lagerbestand reservieren, ohne dass die Kundschaft das wollte. * * Der Origin gilt als vertrauenswuerdig, wenn er zu IRGENDEINEM der in * trustedHosts() aufgefuehrten Hosts passt. Fruehere Fassungen verglichen * ausschliesslich gegen new URL(request.url).host (+ NEXT_PUBLIC_SITE_URL) * — weil Next.js request.url aber aus der internen Bind-Adresse * rekonstruiert, schlug damit jeder Admin-Upload mit "Ungueltige Anfrage." * (403) fehl, sobald der Browser den Server unter einem anderen Namen als * dieser internen Adresse erreichte (oeffentliche Domain hinter dem Proxy, * LAN-IP, 127.0.0.1 statt localhost) und NEXT_PUBLIC_SITE_URL nicht exakt * dazu passte. */ export function isTrustedOrigin(request: Request): boolean { const origin = request.headers.get("origin"); // Kein Origin-Header: moderne Browser senden ihn bei POST/PUT/DELETE sowohl // fuer Cross-Origin- als auch fuer Same-Origin-Anfragen mit, ein fehlender // Header stammt also nicht von einem regulaeren browserbasierten // Cross-Site-Angriff. Wird hier bewusst durchgelassen, um serverseitige/ // Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren. if (!origin) return true; let originHost: string; try { originHost = new URL(origin).host; } catch { return false; } if (!originHost) return false; return trustedHosts(request).includes(originHost); }