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).
This commit is contained in:
Nick Szaunig committed 2026-09-13 23:15:28 +02:00
1 parent a0f61d328c
commit abdebfc477
2 files changed
+119 -27

No files matched your search

+44 -1
View File
@@ -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<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 });
}
@@ -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);
});
});