diff --git a/src/lib/csrf.test.ts b/src/lib/csrf.test.ts index af04fe1..3630411 100644 --- a/src/lib/csrf.test.ts +++ b/src/lib/csrf.test.ts @@ -12,9 +12,14 @@ afterEach(() => { else process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl; }); -function makeRequest(url: string, origin?: string): Request { +function makeRequest( + url: string, + origin?: string, + extraHeaders: Record = {} +): Request { const headers = new Headers(); if (origin !== undefined) headers.set("origin", origin); + for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value); return new Request(url, { method: "POST", headers }); } @@ -65,4 +70,42 @@ describe("isTrustedOrigin", () => { const request = makeRequest("http://app:3000/api/admin/upload", "https://attacker.example"); expect(isTrustedOrigin(request)).toBe(false); }); + + test("accepts an origin matching the Host header even when request.url's host differs", () => { + // Der eigentliche Live-Bug: Next.js rekonstruiert request.url aus der + // internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der + // Browser tatsaechlich adressiert hat. Ist NEXT_PUBLIC_SITE_URL nicht + // (oder nicht exakt passend) gesetzt, schlug damit jeder Admin-Upload + // mit "Ungueltige Anfrage." fehl, obwohl Origin und adressierter Host + // identisch waren. + const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", { + host: "wabenhain.com", + }); + expect(isTrustedOrigin(request)).toBe(true); + }); + + test("accepts an origin matching X-Forwarded-Host set by the reverse proxy", () => { + const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", { + host: "app:3000", + "x-forwarded-host": "wabenhain.com", + }); + expect(isTrustedOrigin(request)).toBe(true); + }); + + test("uses only the first entry of a comma-separated X-Forwarded-Host chain", () => { + const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", { + "x-forwarded-host": "wabenhain.com, internal.example", + }); + expect(isTrustedOrigin(request)).toBe(true); + }); + + test("rejects a forged cross-origin request even though the Host header is trusted", () => { + // Gegenprobe zur Host-Header-Pruefung: ein Cross-Site-POST traegt zwar + // den echten Ziel-Host (der Browser setzt ihn, nicht die Angreiferseite), + // aber den fremden Origin — muss weiterhin abgelehnt werden. + const request = makeRequest("http://localhost:3000/api/admin/upload", "https://attacker.example", { + host: "wabenhain.com", + }); + expect(isTrustedOrigin(request)).toBe(false); + }); }); diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index 80c6339..ddafe9e 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -1,32 +1,81 @@ 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/nginx, siehe Caddyfile bzw. k8s/06-ingress.yaml) weiterreicht. + * Caddy verwirft einen vom Client mitgeschickten X-Forwarded-Host per + * Default, der Wert stammt hier also immer vom Proxy selbst. + * 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 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. + * /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. * - * Akzeptiert den Origin, wenn er zu request.url ODER zu getBaseUrl() - * (NEXT_PUBLIC_SITE_URL) passt — nicht nur zu request.url allein. Hinter - * einem selbst betriebenen Reverse-Proxy (Caddy/nginx, siehe - * docker-compose.yml bzw. k8s/) ist der vom Next.js-Prozess intern - * rekonstruierte Host aus request.url nicht in jeder Proxy-Konstellation - * zuverlaessig derselbe wie der echte, oeffentliche Host, den der Browser im - * Origin-Header sendet (vgl. denselben Grundsatz bereits in lib/url.ts) — - * das fuehrte live faelschlich zu "Ungueltige Anfrage." bei jedem - * Admin-Upload, obwohl der Request tatsaechlich vom eigenen Frontend kam. - * Die zusaetzliche ODER-Bedingung statt eines reinen Ersatzes ist bewusst - * risikoarm: sie akzeptiert nur MEHR legitime Faelle als vorher, verengt - * nichts — verhindert also, dass ein falsch/veraltet gesetztes - * NEXT_PUBLIC_SITE_URL ploetzlich bislang funktionierende Endpunkte wie - * /api/checkout fuer echte Kundschaft blockiert. + * 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"); @@ -37,13 +86,13 @@ export function isTrustedOrigin(request: Request): boolean { // Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren. if (!origin) return true; + let originHost: string; try { - const originHost = new URL(origin).host; - const requestHost = new URL(request.url).host; - if (requestHost === originHost) return true; - const trustedHost = new URL(getBaseUrl()).host; - return trustedHost === originHost; + originHost = new URL(origin).host; } catch { return false; } + if (!originHost) return false; + + return trustedHosts(request).includes(originHost); }