diff --git a/src/lib/csrf.test.ts b/src/lib/csrf.test.ts index 3012527..af04fe1 100644 --- a/src/lib/csrf.test.ts +++ b/src/lib/csrf.test.ts @@ -1,6 +1,17 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { isTrustedOrigin } from "./csrf"; +const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL; + +beforeEach(() => { + delete process.env.NEXT_PUBLIC_SITE_URL; +}); + +afterEach(() => { + if (originalSiteUrl === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; + else process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl; +}); + function makeRequest(url: string, origin?: string): Request { const headers = new Headers(); if (origin !== undefined) headers.set("origin", origin); @@ -37,4 +48,21 @@ describe("isTrustedOrigin", () => { const request = makeRequest("https://wabenhain.com/api/checkout"); expect(isTrustedOrigin(request)).toBe(true); }); + + test("accepts an origin matching NEXT_PUBLIC_SITE_URL even when it differs from request.url's host", () => { + // Reproduziert den Live-Bug: hinter einem selbst betriebenen + // Reverse-Proxy kann request.url intern einen anderen Host tragen als + // den echten, oeffentlichen Host im Origin-Header — ohne diesen + // Fallback wurde das faelschlich als CSRF-Versuch abgelehnt + // ("Ungueltige Anfrage."), obwohl der Request vom eigenen Frontend kam. + process.env.NEXT_PUBLIC_SITE_URL = "https://wabenhain.com"; + const request = makeRequest("http://app:3000/api/admin/upload", "https://wabenhain.com"); + expect(isTrustedOrigin(request)).toBe(true); + }); + + test("still rejects a forged cross-origin request when NEXT_PUBLIC_SITE_URL is configured", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://wabenhain.com"; + const request = makeRequest("http://app:3000/api/admin/upload", "https://attacker.example"); + expect(isTrustedOrigin(request)).toBe(false); + }); }); diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index 2c95ab5..80c6339 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -1,3 +1,5 @@ +import { getBaseUrl } from "@/lib/url"; + /** * Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler. * @@ -10,6 +12,21 @@ * 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. */ export function isTrustedOrigin(request: Request): boolean { const origin = request.headers.get("origin"); @@ -21,9 +38,11 @@ export function isTrustedOrigin(request: Request): boolean { if (!origin) return true; try { - const requestHost = new URL(request.url).host; const originHost = new URL(origin).host; - return requestHost === originHost; + const requestHost = new URL(request.url).host; + if (requestHost === originHost) return true; + const trustedHost = new URL(getBaseUrl()).host; + return trustedHost === originHost; } catch { return false; }