Files
nebula/src/middleware.ts
T
florian.wildeandClaude Opus 5 a122187feb
CI / pruefen (push) Canceled after 0s
feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
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>
2026-08-15 15:38:05 +02:00

182 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" },
],
},
],
};