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:
1 parent
a0f61d328c
commit
abdebfc477
2 files changed
+119
-27
No files matched your search
+44
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+75
-26
@@ -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 <form enctype="text/plain"> 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
|
||||
* <form enctype="text/plain"> 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);
|
||||
}
|
||||
Reference in new issue
Block a user