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:
@@ -0,0 +1,69 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Grobe Bremse gegen automatisiertes Durchprobieren.
|
||||
*
|
||||
* Ergänzt die Kontosperre, ersetzt sie aber nicht: Die Sperre schützt ein
|
||||
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
|
||||
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
|
||||
*
|
||||
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
|
||||
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
|
||||
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
|
||||
* nirgends gespeichert.
|
||||
*/
|
||||
|
||||
type Eintrag = { anzahl: number; bis: number };
|
||||
|
||||
const zaehler = new Map<string, Eintrag>();
|
||||
|
||||
/** Ab wie vielen Versuchen je Zeitfenster abgeblockt wird. */
|
||||
export const LOGIN_ATTEMPTS_PER_WINDOW = 20;
|
||||
const FENSTER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Zählt einen Versuch und meldet, ob das Fenster überschritten ist.
|
||||
* Gibt die verbleibenden Sekunden zurück, sonst `null`.
|
||||
*/
|
||||
export function tooManyAttempts(schluessel: string): number | null {
|
||||
const jetzt = Date.now();
|
||||
const eintrag = zaehler.get(schluessel);
|
||||
|
||||
if (!eintrag || eintrag.bis < jetzt) {
|
||||
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
|
||||
|
||||
// Gelegentlich ausmisten, damit die Tabelle nicht unbegrenzt wächst.
|
||||
if (zaehler.size > 5000) {
|
||||
for (const [key, wert] of zaehler) {
|
||||
if (wert.bis < jetzt) zaehler.delete(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
eintrag.anzahl += 1;
|
||||
if (eintrag.anzahl > LOGIN_ATTEMPTS_PER_WINDOW) {
|
||||
return Math.ceil((eintrag.bis - jetzt) / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Nach erfolgreicher Anmeldung ist der Zähler gegenstandslos. */
|
||||
export function clearAttempts(schluessel: string): void {
|
||||
zaehler.delete(schluessel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absender einer Anfrage, so gut es hinter einem Proxy geht.
|
||||
*
|
||||
* `X-Forwarded-For` ist fälschbar, wenn kein Proxy davorsteht — deshalb ist
|
||||
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
|
||||
* aber die Kontosperre greift weiterhin.
|
||||
*/
|
||||
export function requestOrigin(headers: Headers): string {
|
||||
return (
|
||||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
headers.get("x-real-ip") ||
|
||||
"unbekannt"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user