CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session Management und Password Storage. Das meiste stand schon gut da – Token mit 256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf Punkte blieben. **Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem 2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt 2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt: Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel. **Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer immer auf den alten Parametern – anheben laesst er sich nur dort, wo das Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p, nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu gehasht werden. **Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort- pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423), deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401). Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst hingenommen. **Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde. ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist – bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt. **Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-". Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist – eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides kostet einmalig eine neue Anmeldung. Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und Passwoerter, die den Benutzernamen enthalten, werden abgelehnt. Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab. Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern) und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes Konto gegen die auf ein erfundenes haelt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
||
|
||
/**
|
||
* Erste Verteidigungslinie – laeuft vor jeder Anfrage.
|
||
*
|
||
* 1. CSRF: schreibende Anfragen muessen vom eigenen Origin kommen.
|
||
* 2. Sicherheitskopfzeilen inklusive Content-Security-Policy mit Nonce.
|
||
* 3. Grobe Zugangsschranke: ohne Sitzungscookie geht es zur Anmeldung.
|
||
*
|
||
* Wichtig: Punkt 3 ist nur Bequemlichkeit und Lastschutz, keine
|
||
* Sicherheitsgrenze. Die Middleware laeuft im Edge-Runtime und kann das
|
||
* Token nicht gegen die Datenbank pruefen. Verbindlich entschieden wird in
|
||
* requireUserPage bzw. requireUserApi – also serverseitig in jeder Seite und
|
||
* jedem Endpunkt.
|
||
*/
|
||
|
||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||
|
||
/**
|
||
* Beide moeglichen Namen des Sitzungscookies.
|
||
*
|
||
* Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix
|
||
* verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand
|
||
* der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es
|
||
* geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet
|
||
* requireUserPage bzw. requireUserApi.
|
||
*/
|
||
const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"];
|
||
|
||
/** Ohne Anmeldung erreichbar. */
|
||
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
||
/**
|
||
* Eingehende Webhooks bringen kein Cookie mit – sie kommen von GitHub,
|
||
* GitLab oder Gitea. Ausgewiesen wird sich dort mit einer Signatur ueber den
|
||
* Rumpf, geprueft im Endpunkt selbst (lib/webhooks/verify). Ohne gueltiges
|
||
* Geheimnis passiert nichts.
|
||
*/
|
||
const PUBLIC_API_PREFIXES = ["/api/auth/", "/api/webhooks/"];
|
||
/**
|
||
* Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
|
||
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
|
||
* umgeleitet, liesse sich die Anwendung nicht installieren und die
|
||
* Anmeldeseite haette kein Symbol.
|
||
*/
|
||
const PUBLIC_API_EXACT = ["/api/health", "/manifest.webmanifest"];
|
||
|
||
/** Bilder und aehnliches im Wurzelverzeichnis verraten nichts. */
|
||
const PUBLIC_FILE = /\.(png|svg|ico|webmanifest|txt|webp)$/;
|
||
|
||
function isPublicPath(pathname: string): boolean {
|
||
if (PUBLIC_PATHS.includes(pathname)) return true;
|
||
if (PUBLIC_API_EXACT.includes(pathname)) return true;
|
||
if (PUBLIC_FILE.test(pathname)) return true;
|
||
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||
}
|
||
|
||
/**
|
||
* Gleicher Ursprung?
|
||
*
|
||
* Moderne Browser senden Sec-Fetch-Site bei jeder Anfrage und Origin bei
|
||
* jeder schreibenden. Fehlen beide, stammt die Anfrage nicht aus einem
|
||
* Browser (curl, Skript, Cronjob) und ist damit kein CSRF-Vektor – solche
|
||
* Aufrufe bleiben erlaubt, damit die API skriptbar bleibt.
|
||
*/
|
||
function isSameOrigin(request: NextRequest): boolean {
|
||
const site = request.headers.get("sec-fetch-site");
|
||
if (site) return site === "same-origin" || site === "none";
|
||
|
||
const origin = request.headers.get("origin");
|
||
if (origin) {
|
||
try {
|
||
return new URL(origin).host === request.headers.get("host");
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function buildCsp(nonce: string, isDev: boolean): string {
|
||
const scriptSrc = isDev
|
||
? `'self' 'nonce-${nonce}' 'unsafe-eval' 'strict-dynamic'`
|
||
: `'self' 'nonce-${nonce}' 'strict-dynamic'`;
|
||
|
||
return [
|
||
"default-src 'self'",
|
||
`script-src ${scriptSrc}`,
|
||
// Inline-Styles sind noetig: React setzt style-Attribute fuer die
|
||
// Akzentfarben, Next liefert kritisches CSS inline aus.
|
||
"style-src 'self' 'unsafe-inline'",
|
||
"img-src 'self' data: blob:",
|
||
"font-src 'self' data:",
|
||
// Der Browser spricht nur mit der eigenen Instanz; die Git-APIs ruft
|
||
// ausschliesslich der Server auf.
|
||
"connect-src 'self'",
|
||
"object-src 'none'",
|
||
"base-uri 'none'",
|
||
"form-action 'self'",
|
||
"frame-ancestors 'none'",
|
||
"manifest-src 'self'",
|
||
].join("; ");
|
||
}
|
||
|
||
export function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl;
|
||
const isDev = process.env.NODE_ENV === "development";
|
||
|
||
// ── 1. CSRF ────────────────────────────────────────────────
|
||
// Webhooks kommen von einem fremden Server und koennen keinen passenden
|
||
// Origin mitbringen. Die Herkunftspruefung schuetzt gegen den Browser
|
||
// eines angemeldeten Nutzers – hier gibt es keinen, und ausgewiesen wird
|
||
// sich stattdessen mit der Signatur ueber den Rumpf.
|
||
const istWebhook = pathname.startsWith("/api/webhooks/");
|
||
if (!istWebhook && !SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
|
||
return NextResponse.json(
|
||
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
|
||
{ status: 403 },
|
||
);
|
||
}
|
||
|
||
// ── 2. Zugangsschranke ─────────────────────────────────────
|
||
const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value));
|
||
if (!hasSession && !isPublicPath(pathname)) {
|
||
if (pathname.startsWith("/api/")) {
|
||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||
}
|
||
const target = request.nextUrl.clone();
|
||
target.pathname = "/login";
|
||
target.search = "";
|
||
// Nur den Pfad merken, keine absolute URL – sonst waere das ein
|
||
// offener Redirect.
|
||
if (pathname !== "/") target.searchParams.set("weiter", pathname);
|
||
return NextResponse.redirect(target);
|
||
}
|
||
|
||
// ── 3. Kopfzeilen ──────────────────────────────────────────
|
||
const nonce = crypto.randomUUID().replace(/-/g, "");
|
||
const csp = buildCsp(nonce, isDev);
|
||
|
||
// Next liest die Nonce aus der CSP der Anfrage und setzt sie auf seine
|
||
// eigenen Skript-Tags.
|
||
const requestHeaders = new Headers(request.headers);
|
||
requestHeaders.set("x-nonce", nonce);
|
||
requestHeaders.set("Content-Security-Policy", csp);
|
||
|
||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||
|
||
response.headers.set("Content-Security-Policy", csp);
|
||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||
response.headers.set("X-Frame-Options", "DENY");
|
||
response.headers.set("Referrer-Policy", "same-origin");
|
||
response.headers.set(
|
||
"Permissions-Policy",
|
||
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||
);
|
||
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
||
response.headers.set("X-DNS-Prefetch-Control", "off");
|
||
|
||
return response;
|
||
}
|
||
|
||
export const config = {
|
||
matcher: [
|
||
/*
|
||
* Alles ausser den statischen Auslieferungen von Next. Die brauchen
|
||
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
|
||
*/
|
||
{
|
||
// _next/image ist bewusst *nicht* ausgenommen: der Bildoptimierer ist
|
||
// abgeschaltet (siehe next.config.ts), und was von ihm uebrig bleibt,
|
||
// soll nicht ohne Sitzung erreichbar sein.
|
||
source: "/((?!_next/static|favicon.ico|icon.svg).*)",
|
||
missing: [
|
||
{ type: "header", key: "next-router-prefetch" },
|
||
{ type: "header", key: "purpose", value: "prefetch" },
|
||
],
|
||
},
|
||
],
|
||
};
|