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
+4
View File
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
@@ -26,6 +27,9 @@ const updateSchema = z.object({
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
})
.optional(),
/** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(),
+6 -2
View File
@@ -5,6 +5,7 @@ import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
@@ -25,8 +26,11 @@ const createSchema = z.object({
error: "Das ist keine gültige E-Mail-Adresse.",
}),
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
password: z
.string()
.min(MIN_PASSWORD_LENGTH, { error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, })
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
isInstanceAdmin: z.boolean().default(false),
});
+102 -31
View File
@@ -5,7 +5,9 @@ import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
needsRehash,
verifyPassword,
} from "@/lib/auth/password";
import {
@@ -13,6 +15,11 @@ import {
destroyAllSessions,
destroySession,
} from "@/lib/auth/session";
import {
clearAttempts,
requestOrigin,
tooManyAttempts,
} from "@/lib/auth/throttle";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
@@ -23,6 +30,13 @@ export const dynamic = "force-dynamic";
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
/**
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
* Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen
* denen echter Hashes — sonst wäre die Rechnung zu schnell.
*/
const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA";
const emailSchema = z
.string()
.trim()
@@ -36,28 +50,42 @@ const emailSchema = z
const loginSchema = z.object({
action: z.literal("login"),
email: emailSchema,
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
password: z
.string()
.min(1, { error: "Bitte gib dein Passwort ein." })
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
});
const setupSchema = z.object({
action: z.literal("setup"),
email: emailSchema,
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
});
const logoutSchema = z.object({ action: z.literal("logout") });
const changePasswordSchema = z.object({
action: z.literal("change-password"),
currentPassword: z.string().min(1, {
error: "Bitte gib dein bisheriges Passwort ein.",
}),
newPassword: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
}),
currentPassword: z
.string()
.min(1, { error: "Bitte gib dein bisheriges Passwort ein." })
.max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }),
newPassword: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`,
})
.max(MAX_PASSWORD_LENGTH, {
error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`,
}),
});
const bodySchema = z.discriminatedUnion("action", [
@@ -117,7 +145,7 @@ export async function POST(request: NextRequest): Promise<Response> {
if (parsed.data.action === "setup") {
return await handleSetup(parsed.data);
}
return await handleLogin(parsed.data);
return await handleLogin(parsed.data, requestOrigin(request.headers));
} catch (error) {
return errorResponse(error);
}
@@ -188,14 +216,31 @@ async function handleSetup(
);
}
const user = await prisma.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash: await hashPassword(input.password),
isInstanceAdmin: true,
// Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt.
const passwordHash = await hashPassword(input.password);
// Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und
// dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann
// gäbe es zwei Administratoren, von denen einer ungebeten ist.
const user = await prisma.$transaction(
async (tx) => {
if ((await tx.user.count()) > 0) {
throw new QrRequestError(
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
403,
);
}
return tx.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash,
isInstanceAdmin: true,
},
});
},
});
{ isolationLevel: "Serializable" },
);
await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 });
@@ -203,7 +248,18 @@ async function handleSetup(
async function handleLogin(
input: z.infer<typeof loginSchema>,
absender: string,
): Promise<Response> {
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
const wartezeit = tooManyAttempts(`login:${absender}`);
if (wartezeit !== null) {
throw new QrRequestError(
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
429,
);
}
const user = await prisma.user.findUnique({ where: { email: input.email } });
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
@@ -215,17 +271,10 @@ async function handleLogin(
if (!user) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
await verifyPassword(input.password, DUMMY_HASH);
throw invalid;
}
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
@@ -236,11 +285,12 @@ async function handleLogin(
);
}
// Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe
// Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten,
// dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben.
if (!user.passwordHash) {
throw new QrRequestError(
"Dieses Konto meldet sich über den externen Anbieter an. Bitte den Knopf oben benutzen.",
403,
);
await verifyPassword(input.password, DUMMY_HASH);
throw invalid;
}
if (!(await verifyPassword(input.password, user.passwordHash))) {
@@ -258,11 +308,32 @@ async function handleLogin(
throw invalid;
}
// Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto
// nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht.
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
// Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern.
// Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt.
const passwordHash = needsRehash(user.passwordHash)
? await hashPassword(input.password)
: undefined;
await prisma.user.update({
where: { id: user.id },
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
data: {
failedLogins: 0,
lockedUntil: null,
lastLoginAt: new Date(),
...(passwordHash ? { passwordHash } : {}),
},
});
clearAttempts(`login:${absender}`);
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+2 -1
View File
@@ -40,7 +40,8 @@ export async function GET(
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
"Content-Security-Policy": "sandbox; default-src 'none'",
"Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
"Cache-Control": "public, max-age=31536000, immutable",
+5 -1
View File
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, jsonError } from "@/lib/http";
import { assertSameOrigin, errorResponse, jsonError } from "@/lib/http";
import {
cleanupOrphanedUploads,
isUploadKind,
@@ -22,6 +22,10 @@ export const dynamic = "force-dynamic";
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
// Diese Route liegt außerhalb des Proxys (siehe src/proxy.ts) und prüft
// die Herkunft deshalb selbst.
assertSameOrigin(request.headers);
const actor = await getActor();
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.