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:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user