import { NextResponse, type NextRequest } from "next/server"; import { oeffentlicheAdresse } from "@/lib/adressen"; import { SITZUNG_COOKIE } from "@/lib/sitzung"; /** * In Next 16 heisst die frueher middleware.ts genannte Datei proxy.ts und laeuft * in der Node-Laufzeit. * * Sie tut zweierlei: * * 1. Content-Security-Policy mit einer Nonce je Anfrage. Die muss dynamisch * sein, deshalb steht sie nicht bei den festen Kopfzeilen in next.config.ts. * 2. Ein billiges Tor: ohne Sitzungscookie geht es gar nicht erst weiter, und * der Benutzer landet auf der Anmeldeseite – mit dem urspruenglichen Ziel im * Gepaeck. Geprueft wird das Cookie hier NICHT; das macht die Huelle unter * src/app/(app)/layout.tsx gegen die Datenbank. Diese Trennung spart bei * jeder Anfrage ohne Cookie eine Datenbankabfrage, ohne die Sicherheit an * den Proxy zu haengen. */ /** * Pfade, die ohne Anmeldung erreichbar sein muessen. * * Exportiert, damit tests/zugang.test.ts die Liste pruefen kann: ein neuer oeffentlicher * Pfad ist eine Sicherheitsentscheidung und soll jemanden zwingen, sie aufzuschreiben. */ export const OEFFENTLICH: readonly string[] = [ "/anmelden", // Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden. "/api/auth", // Healthcheck fuer Docker und den Reverse Proxy; verraet nur die Version. "/api/gesundheit", "/manifest.webmanifest", "/brand", "/icons", ]; export function istOeffentlich(pfad: string): boolean { return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`)); } /** * Die Content-Security-Policy als reine Funktion — damit sie sich testen laesst. * * Im Entwicklungsmodus braucht es zwei Lockerungen, und nur dort: * * - **`unsafe-eval`**: React und Turbopack werten im Entwicklungsmodus Code zur * Laufzeit aus (Fast Refresh, Aufrufketten ueber Umgebungsgrenzen hinweg). * Ohne diese Erlaubnis **hydriert die Seite gar nicht** — sie sieht richtig * aus, aber kein Knopf tut etwas. Das ist der heimtueckische Teil: ein * Screenshot sieht aus wie immer, und erst ein Klick faellt auf. * - **`ws:` und `wss:`**: Fast Refresh haelt eine WebSocket-Verbindung. * * Im gebauten Stand braucht React beides nicht. Die ausgelieferte Anwendung * bekommt deshalb weiterhin die strenge Fassung — ein Test haelt das fest, * damit die Lockerung nicht eines Tages mitgeliefert wird. */ export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string { return [ `default-src 'self'`, `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`, // Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile. `style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`, `font-src 'self'`, `connect-src 'self'${entwicklung ? " ws: wss:" : ""}`, `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`, `object-src 'none'`, ].join("; "); } export function proxy(anfrage: NextRequest): NextResponse { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const pfad = anfrage.nextUrl.pathname; if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) { /* * Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#004). * Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die * Anmeldeseite als HTML mit Status 200 – fuer den Client saehe das wie eine * gueltige Antwort aus. */ if (pfad.startsWith("/api/")) { return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 }); } const ziel = pfad + anfrage.nextUrl.search; const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin); if (ziel !== "/") anmelden.searchParams.set("ziel", ziel); return NextResponse.redirect(anmelden); } const richtlinie = sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production"); const kopfzeilen = new Headers(anfrage.headers); kopfzeilen.set("x-nonce", nonce); // Server Components kennen den Pfad nicht von sich aus; die Huelle braucht ihn // fuer den aktiven Eintrag in der Navigation und fuer den Ruecksprung nach der // Anmeldung. kopfzeilen.set("x-pfad", pfad); kopfzeilen.set("x-abfrage", anfrage.nextUrl.search); const antwort = NextResponse.next({ request: { headers: kopfzeilen } }); antwort.headers.set("Content-Security-Policy", richtlinie); return antwort; } export const config = { /* * Alles unter `_next` bleibt aussen vor, nicht nur `static` und `image`. * * Die CSP gehoert auf das Dokument, nicht auf jeden Programmteil, und das Tor * schuetzt Seiten, keine Bausteine. Vorher lief der Proxy auch auf * `/_next/hmr` — der Verbindungsaufbau von Fast Refresh ist ein * WebSocket-Upgrade, und `NextResponse.next()` mit geaenderten Kopfzeilen * macht daraus eine gewoehnliche Antwort. Die Verbindung scheiterte, und im * Entwicklungsmodus hydrierte die Seite nicht mehr: sie sah richtig aus, aber * kein Knopf tat etwas. */ matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"], };