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).
112 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|