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
+36 -5
View File
@@ -34,9 +34,16 @@ function scryptAsync(
*
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
* entwerten.
* entwerten — `needsRehash()` erkennt dann veraltete Hashes.
*
* N = 2^17, r = 8, p = 1 entspricht der Empfehlung des OWASP Password Storage
* Cheat Sheet (Stand 2026). Das kostet rund 128 MiB Speicher je Berechnung;
* `maxmem` muss darüber liegen, sonst weigert sich Node.
*/
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
const PARAMS = { N: 131072, r: 8, p: 1, keyLength: 64, saltLength: 16 };
/** 128 · N · r plus Reserve. */
const MAXMEM = 192 * 1024 * 1024;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(PARAMS.saltLength);
@@ -48,8 +55,8 @@ export async function hashPassword(password: string): Promise<string> {
N: PARAMS.N,
r: PARAMS.r,
p: PARAMS.p,
// Der Standardwert reicht für N = 32768 nicht aus.
maxmem: 256 * 1024 * 1024,
// Der Standardwert reicht für dieses N nicht aus.
maxmem: MAXMEM,
},
);
@@ -92,7 +99,7 @@ export async function verifyPassword(
N,
r,
p,
maxmem: 256 * 1024 * 1024,
maxmem: MAXMEM,
});
} catch {
return false;
@@ -103,9 +110,33 @@ export async function verifyPassword(
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
/**
* Erkennt Hashes, die mit schwächeren Parametern erzeugt wurden.
*
* Beim nächsten erfolgreichen Anmelden wird das Passwort dann neu gehasht —
* anders kommt man nicht an das Klartextpasswort, um den Hash zu erneuern.
*/
export function needsRehash(stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
return (
Number(parts[1]) < PARAMS.N ||
Number(parts[2]) < PARAMS.r ||
Number(parts[3]) < PARAMS.p
);
}
/** Mindestanforderungen an ein Passwort. */
export const MIN_PASSWORD_LENGTH = 10;
/**
* Obergrenze — nicht aus Sicherheitsgründen, sondern gegen Überlastung:
* scrypt über ein mehrere Megabyte langes „Passwort" würde den Server
* beschäftigen, und das an einer Stelle, die ohne Anmeldung erreichbar ist.
* 128 Zeichen sind weit jenseits jeder sinnvollen Passphrase.
*/
export const MAX_PASSWORD_LENGTH = 128;
export function describePasswordRules(): string {
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
}
+80 -8
View File
@@ -1,14 +1,42 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { prisma } from "@/lib/db";
export const SESSION_COOKIE = "qrgen_session";
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
const SESSION_DAYS = 30;
/**
* Wie lange eine Sitzung ohne Aktivität gültig bleibt.
*
* Das OWASP Session Management Cheat Sheet nennt für Anwendungen mit
* geringem Risiko 15–30 Minuten Leerlauf und 4–8 Stunden absolut. Das ist auf
* öffentlich erreichbare Anwendungen mit vielen Nutzern gemünzt; für ein
* internes Werkzeug, an dem man täglich arbeitet, wäre es eine Zumutung.
* Deshalb großzügigere Vorgaben — aber beide über die Umgebung einstellbar,
* damit strengere Werte eine Änderung in der `.env` sind und kein Umbau.
*/
function tage(name: string, voreinstellung: number): number {
const roh = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(roh) && roh > 0 && roh <= 365 ? roh : voreinstellung;
}
const MS_PRO_TAG = 24 * 60 * 60 * 1000;
/** Ohne Aktivität läuft die Sitzung nach so vielen Tagen ab. */
function idleDays(): number {
return tage("SESSION_IDLE_DAYS", 30);
}
/**
* Und spätestens nach so vielen Tagen ab dem Anmelden — unabhängig davon,
* wie oft sie benutzt wurde. Ohne diese Grenze könnte eine einmal gestohlene
* Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
*/
function absoluteDays(): number {
return tage("SESSION_MAX_DAYS", 90);
}
/**
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
@@ -19,7 +47,25 @@ function hashToken(token: string): string {
}
function expiryFromNow(): Date {
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
return new Date(Date.now() + idleDays() * MS_PRO_TAG);
}
/**
* Läuft die Verbindung über HTTPS?
*
* Neben der `BASE_URL` zählt auch `X-Forwarded-Proto`: Hinter einem Proxy,
* der TLS beendet, spricht die Anwendung selbst nur HTTP — der Cookie muss
* trotzdem als `Secure` gesetzt werden.
*/
async function isHttps(): Promise<boolean> {
if ((process.env.BASE_URL ?? "").startsWith("https://")) return true;
try {
const kopf = await headers();
return (kopf.get("x-forwarded-proto") ?? "").split(",")[0].trim() === "https";
} catch {
// Außerhalb einer Anfrage — dann bleibt nur die BASE_URL.
return false;
}
}
export type SessionUser = {
@@ -32,10 +78,18 @@ export type SessionUser = {
/** Legt eine Sitzung an und setzt den Cookie. */
export async function createSession(userId: string): Promise<void> {
// 32 Byte aus dem CSPRNG — weit über den 128 Bit, die OWASP mindestens
// verlangt.
const token = randomBytes(32).toString("base64url");
const jetzt = Date.now();
await prisma.session.create({
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
data: {
tokenHash: hashToken(token),
userId,
expiresAt: expiryFromNow(),
absoluteExpiresAt: new Date(jetzt + absoluteDays() * MS_PRO_TAG),
},
});
const store = await cookies();
@@ -45,7 +99,7 @@ export async function createSession(userId: string): Promise<void> {
path: "/",
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
secure: await isHttps(),
expires: expiryFromNow(),
});
}
@@ -89,7 +143,11 @@ export async function getSessionUser(): Promise<SessionUser | null> {
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
// Abgelaufen — durch Leerlauf oder weil die absolute Grenze erreicht ist.
if (
session.expiresAt.getTime() < Date.now() ||
session.absoluteExpiresAt.getTime() < Date.now()
) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
@@ -100,12 +158,26 @@ export async function getSessionUser(): Promise<SessionUser | null> {
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
const anHourAgo = Date.now() - 60 * 60 * 1000;
if (session.lastSeenAt.getTime() < anHourAgo) {
// Nie über die absolute Grenze hinaus verlängern.
const verlaengert = expiryFromNow();
await prisma.session
.update({
where: { id: session.id },
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
data: {
lastSeenAt: new Date(),
expiresAt:
verlaengert < session.absoluteExpiresAt
? verlaengert
: session.absoluteExpiresAt,
},
})
.catch(() => undefined);
// Beiläufig aufräumen — abgelaufene Sitzungen sind harmlos, sollen sich
// aber nicht endlos ansammeln.
void prisma.session
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
.catch(() => undefined);
}
return {
+69
View File
@@ -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"
);
}
+41
View File
@@ -1,3 +1,44 @@
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Kommt diese Anfrage von der eigenen Oberfläche?
*
* Zweite Verteidigungslinie neben `SameSite=Lax` am Sitzungs-Cookie. 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.
*
* Verglichen wird gegen den Host der Anfrage, nicht gegen `BASE_URL`: Die
* Anwendung ist absichtlich unter mehreren Domains erreichbar.
*
* Fehlt der `Origin`-Header, gilt die Anfrage als eigen: Browser setzen ihn
* bei fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar
* nicht. Ein Abweisen träfe nur die Falschen.
*/
export function isSameOrigin(headers: Headers): boolean {
const origin = headers.get("origin");
if (!origin) return true;
let herkunft: URL;
try {
herkunft = new URL(origin);
} catch {
return false;
}
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return Boolean(host) && herkunft.host === host;
}
export function assertSameOrigin(headers: Headers): void {
if (!isSameOrigin(headers)) {
throw new QrRequestError(
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
403,
);
}
}
export function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status });
}