Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m6s
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.
102 lines
4.5 KiB
TypeScript
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);
|
|
}
|