Files
Wabenhain/src/lib/csrf.ts
T
Nick Szaunig e8f0bd4f55
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m6s
fix: Ingress auf Traefik umstellen — die nginx-Annotationen waren wirkungslos
Der Zielcluster nutzt Traefik, nicht ingress-nginx. Saemtliche Annotationen
der Form nginx.ingress.kubernetes.io/* ignoriert Traefik ersatzlos und
still: sie erzeugen keinen Fehler, wirken aber auch nicht. Das betraf das
Upload-Limit (proxy-body-size) ebenso wie die Zeitlimits, und
ingressClassName zeigte auf "nginx".

Damit aendert sich auch die Diagnose des fehlgeschlagenen Video-Uploads.
Belegt aus Traefiks Quelltext (pkg/config/static/static_config.go,
SetDefaults) und der Referenzdokumentation:

- readTimeout des EntryPoints: Default 60s, beschrieben als "maximum
  duration for reading the entire request, including the body". Er umfasst
  also die gesamte Uebertragung des Uploads. Bei 20-100 MB ueber eine
  normale Upload-Leitung ist das schnell ueberschritten — 80 MB bei
  10 Mbit/s sind rund 64 Sekunden. Laeuft er ab, kappt Traefik die
  Verbindung und antwortet mit einer eigenen HTML-Fehlerseite. Das ist die
  wahrscheinlichste Quelle des "Unexpected token '<'" im Browser.
- responseHeaderTimeout: von SetDefaults NICHT gesetzt, bleibt 0, und
  "If zero, no timeout exists". Die Dauer des ffmpeg-Transcodings ist bei
  Traefik also NICHT begrenzt — anders als zuvor angenommen.
- Body-Groesse: Traefik erzwingt keine Obergrenze (begrenzt ist nur
  maxHeaderBytes mit 1 MB fuer die Header). Ein Gegenstueck zu nginx'
  proxy-body-size wird nicht gebraucht.

readTimeout gehoert zur statischen EntryPoint-Konfiguration und laesst sich
nicht per Ingress-Annotation setzen. Das Manifest dokumentiert deshalb
ausfuehrlich, welche Einstellung in der Traefik-Installation noetig ist
(Helm: ports.websecure.transport.respondingTimeouts.readTimeout: 600s).

Weiter angepasst: ingressClassName auf "traefik", HTTPS-Umleitung ueber eine
Traefik-Middleware statt der nginx-Annotation, und der Hinweis zu
X-Forwarded-For nennt jetzt Traefiks forwardedHeaders.trustedIPs — davon
haengt ab, ob lib/rate-limit.ts dem Header trauen darf. Die Kommentare in
lib/uploads.ts und lib/csrf.ts verwiesen auf die entfernten
nginx-Einstellungen und sind entsprechend berichtigt.
2026-09-14 22:48:13 +02:00

102 lines
4.5 KiB
TypeScript

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 bzw. Traefik, siehe Caddyfile bzw. k8s/06-ingress.yaml)
* weiterreicht. Caddy verwirft einen vom Client mitgeschickten
* X-Forwarded-Host per Default; bei Traefik entscheidet darueber
* "forwardedHeaders.trustedIPs" des EntryPoints — nur fuer dort
* aufgefuehrte Absender werden eingehende X-Forwarded-*-Header
* uebernommen, von allen anderen ersetzt.
* 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 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.
*
* 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");
// Kein Origin-Header: moderne Browser senden ihn bei POST/PUT/DELETE sowohl
// fuer Cross-Origin- als auch fuer Same-Origin-Anfragen mit, ein fehlender
// Header stammt also nicht von einem regulaeren browserbasierten
// Cross-Site-Angriff. Wird hier bewusst durchgelassen, um serverseitige/
// Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren.
if (!origin) return true;
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false;
}
if (!originHost) return false;
return trustedHosts(request).includes(originHost);
}