Anmeldung und Sitzungen nach OWASP durchgesehen und gehaertet

Zehn Befunde, alle behoben. Die wichtigsten:

scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17.
Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden
still erneuert — das ist der einzige Moment, in dem das Klartextpasswort
vorliegt.

Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette
scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128
Zeichen, geprueft vor dem Hashen.

Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich,
dass das allein nicht genuegt — besonders beim Upload, der multipart
entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt
wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage
geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains).

Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und
per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options,
nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg.

Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette
durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS
neben SESSION_IDLE_DAYS, beide einstellbar.

Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde
Konten bei der Anmeldung, eine Bremse je Absender gegen breites
Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen
werden aufgeraeumt.

Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention
umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert
Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst
unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst.

Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
This commit is contained in:
Florian Wilde
2026-08-15 08:31:29 +02:00
parent 1064915d37
commit 75252b32b4
17 changed files with 708 additions and 48 deletions
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse, type NextRequest } from "next/server";
import { isSameOrigin } from "@/lib/http";
/**
* Läuft vor jeder Anfrage: setzt Schutzheader und weist schreibende Zugriffe
* von fremden Seiten ab.
*
* Heißt `proxy.ts`, weil Next.js die Datei `middleware.ts` in Version 16
* umbenannt hat — siehe node_modules/next/dist/docs/01-app/03-api-reference/
* 03-file-conventions/proxy.md.
*
* ## Warum eine Origin-Prüfung, obwohl der Cookie `SameSite=Lax` ist
*
* Das OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
* genügt: Es wirkt auf der Ebene der registrierbaren Domain, nicht des
* Ursprungs, und bei `Lax` gehen Cookies bei einfachen Navigationen weiterhin
* mit. Der Upload-Endpunkt nimmt zudem `multipart/form-data` entgegen — das
* kann ein HTML-Formular von einer fremden Seite abschicken.
*
* Fehlt der `Origin`-Header ganz, wird durchgelassen: Browser setzen ihn bei
* fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
* Ein Abweisen träfe also nur die Falschen.
*/
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Schutzheader.
*
* Bewusst ohne vollständige `Content-Security-Policy` für Skripte: Next.js
* bringt eingebettete Skripte mit, dafür bräuchte es Nonces durch die ganze
* Anwendung. Eine halbherzige Policy mit `unsafe-inline` sähe nur nach
* Schutz aus. Was hier steht, wirkt dagegen sofort.
*/
const HEADER: Record<string, string> = {
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
// einer harmlos aussehenden Seite verstecken und fernsteuern.
"Content-Security-Policy": "frame-ancestors 'none'",
"X-Frame-Options": "DENY",
// Der Browser soll den Inhaltstyp nicht raten.
"X-Content-Type-Options": "nosniff",
// Keine vollständigen Adressen an fremde Seiten weitergeben — die
// Kurz-URLs stünden sonst in fremden Protokollen.
"Referrer-Policy": "strict-origin-when-cross-origin",
// Nichts davon braucht die Anwendung.
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
};
export function proxy(request: NextRequest) {
if (SCHREIBEND.has(request.method) && !isSameOrigin(request.headers)) {
return NextResponse.json(
{
error:
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
},
{ status: 403 },
);
}
const response = NextResponse.next();
for (const [name, wert] of Object.entries(HEADER)) {
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
// Dateien —, sollen sie behalten.
if (!response.headers.has(name)) response.headers.set(name, wert);
}
return response;
}
export const config = {
/*
* Ausgenommen sind statische Dateien und `/api/uploads` — letzteres aus
* zwei Gründen:
*
* 1. Next.js puffert den Anfragekörper, damit ihn Proxy und Route beide
* lesen können; jenseits von 10 MB wird abgeschnitten. Eine hochgeladene
* Datei an der Größengrenze käme dann unvollständig an.
* 2. Die Auslieferung setzt eigene Kopfzeilen — eine Sandbox-Policy, die
* schärfer ist als die hier.
*
* Die Herkunftsprüfung entfällt dadurch nicht: Die Upload-Route ruft
* `assertSameOrigin` selbst auf.
*/
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/uploads).*)"],
};