Files
Wabenhain/src/lib/csrf.test.ts
T
Nick Szaunig abdebfc477 fix: Admin-Upload scheiterte an der CSRF-Pruefung (403 "Ungueltige Anfrage.")
Die Same-Origin-Pruefung verglich den Origin-Header gegen
new URL(request.url).host. Next.js rekonstruiert request.url aber aus der
internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
Browser tatsaechlich adressiert hat — reproduzierbar liefert request.url
"localhost:3999", waehrend der Browser "127.0.0.1:3999" aufgerufen hat.

Damit schlug jeder Admin-Upload mit 403 fehl, sobald die Seite unter einem
anderen Namen als dieser internen Adresse erreicht wurde: oeffentliche
Domain hinter dem Reverse-Proxy, LAN-IP, oder schlicht 127.0.0.1 statt
localhost. Der 2025 nachgeruestete Fallback auf NEXT_PUBLIC_SITE_URL half
nur, wenn diese Variable gesetzt ist und exakt passt — in .env fehlt sie.

Der Origin wird jetzt gegen alle plausiblen eigenen Hosts geprueft, in
dieser Reihenfolge: X-Forwarded-Host (vom eigenen Proxy gesetzt, Caddy
verwirft einen vom Client mitgeschickten Wert), Host (was der Browser
adressiert hat — derselbe Vergleich, den Next.js fuer Server Actions
intern anstellt), request.url und zuletzt NEXT_PUBLIC_SITE_URL.

Der CSRF-Schutz bleibt dabei vollstaendig: eine fremde Seite kann bei
einem Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen — den
Host setzt der Browser aus der Ziel-URL, eigene Header liessen sich nur
per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
Endpunkt nicht beantwortet. Ein gefaelschter POST traegt also weiterhin
zwangslaeufig einen fremden Origin und wird abgewiesen (durch Test und
manuelle Gegenprobe belegt).
2026-09-13 23:15:28 +02:00

112 lines
4.9 KiB
TypeScript

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,
extraHeaders: Record<string, string> = {}
): 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 });
}
describe("isTrustedOrigin", () => {
test("accepts a same-origin request", () => {
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com");
expect(isTrustedOrigin(request)).toBe(true);
});
test("accepts a same-origin request even with an explicit default port in the URL", () => {
const request = makeRequest("https://wabenhain.com:443/api/checkout", "https://wabenhain.com");
expect(isTrustedOrigin(request)).toBe(true);
});
test("rejects a cross-origin request (forged CSRF attempt)", () => {
const request = makeRequest("https://wabenhain.com/api/checkout", "https://attacker.example");
expect(isTrustedOrigin(request)).toBe(false);
});
test("rejects a cross-origin request even from a similarly named host", () => {
const request = makeRequest("https://wabenhain.com/api/checkout", "https://wabenhain.com.attacker.example");
expect(isTrustedOrigin(request)).toBe(false);
});
test("rejects a malformed Origin header instead of throwing", () => {
const request = makeRequest("https://wabenhain.com/api/checkout", "not-a-valid-url");
expect(isTrustedOrigin(request)).toBe(false);
});
test("allows a request with no Origin header (e.g. server-to-server calls)", () => {
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);
});
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);
});
});