OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
80 lines
3.0 KiB
TypeScript
80 lines
3.0 KiB
TypeScript
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. */
|
||
const OEFFENTLICH: readonly string[] = [
|
||
"/anmelden",
|
||
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
|
||
"/api/auth",
|
||
// Healthcheck fuer Docker und den Reverse Proxy.
|
||
"/api/gesundheit",
|
||
"/manifest.webmanifest",
|
||
"/icon.svg",
|
||
];
|
||
|
||
export function istOeffentlich(pfad: string): boolean {
|
||
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
||
}
|
||
|
||
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)) {
|
||
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 = [
|
||
`default-src 'self'`,
|
||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||
`style-src 'self' 'unsafe-inline'`,
|
||
`img-src 'self' data: blob:`,
|
||
`font-src 'self'`,
|
||
// Die Kamera des Scanners liefert Bilddaten als blob:.
|
||
`media-src 'self' blob:`,
|
||
`connect-src 'self'`,
|
||
`frame-ancestors 'none'`,
|
||
`base-uri 'self'`,
|
||
`form-action 'self'`,
|
||
`object-src 'none'`,
|
||
].join("; ");
|
||
|
||
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 = {
|
||
// Statische Dateien und Bilder brauchen weder CSP-Nonce noch Tor.
|
||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|webp|woff2?)$).*)"],
|
||
};
|