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
+9
View File
@@ -26,6 +26,15 @@ AUTH_ENABLED=false
# grosse Werte machen die Sicherung entsprechend schwer.
MAX_UPLOAD_MB=10
# Sitzungsdauer in Tagen.
# SESSION_IDLE_DAYS — ohne Aktivitaet laeuft die Sitzung ab.
# SESSION_MAX_DAYS — harte Obergrenze ab dem Anmelden, unabhaengig davon,
# wie oft sie benutzt wurde.
# OWASP empfiehlt fuer oeffentliche Anwendungen deutlich kuerzere Werte
# (Minuten bzw. Stunden); siehe docs/sicherheit.md.
SESSION_IDLE_DAYS=30
SESSION_MAX_DAYS=90
# --- Anmeldung ueber Authentik oder einen anderen OIDC-Anbieter -------------
# Setzt AUTH_ENABLED=true voraus. Einrichtung siehe docs/authentik.md.
OIDC_ENABLED=false
+2
View File
@@ -42,6 +42,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
Rollen und der Weg zurück
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
Härtungen und was bewusst offen bleibt
---
+2
View File
@@ -39,6 +39,8 @@ services:
# Anmeldung mit Benutzern, Gruppen und Rollen.
AUTH_ENABLED: ${AUTH_ENABLED:-false}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
+247
View File
@@ -0,0 +1,247 @@
# Anmeldung und Sitzungen — Durchsicht nach OWASP
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
Geprüft gegen:
- [Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
> **Wozu das gehört.** Die Anwendung ist ein selbst gehostetes Werkzeug für
> ein Team, nicht ein öffentlicher Dienst mit Zehntausenden Konten. Wo eine
> Empfehlung erkennbar auf den zweiten Fall gemünzt ist, steht unten, warum
> hier etwas anderes gilt — und wie man es strenger einstellt.
---
## Was behoben wurde
### 1. scrypt lag unter der empfohlenen Stärke
**Befund:** Die Passwörter wurden mit scrypt bei `N = 2^15` gehasht. Das
Merkblatt nennt `N = 2^17 (128 MiB), r = 8, p = 1`.
**Behoben:** `N = 2^17`. Alte Hashes bleiben gültig — die Parameter stehen im
Hash. Beim nächsten erfolgreichen Anmelden wird das Passwort still neu
gehasht (`needsRehash()`); das ist der einzige Moment, in dem das
Klartextpasswort vorliegt.
**Nebenwirkung:** Ein Anmeldevorgang kostet jetzt spürbar mehr Rechenzeit und
rund 128 MiB Arbeitsspeicher. Genau das ist der Zweck.
### 2. Passwörter hatten keine Obergrenze
**Befund:** Die Eingabe war nach oben offen. Ein „Passwort“ von mehreren
Megabyte hätte scrypt beschäftigt — an einem Endpunkt, der **ohne Anmeldung**
erreichbar ist. Ein Denial-of-Service mit wenigen Anfragen.
**Behoben:** 128 Zeichen, geprüft vor dem Hashen, in allen drei Formularen
(Anmelden, Ersteinrichtung, Passwortwechsel) sowie in der Benutzerverwaltung.
### 3. Kein CSRF-Schutz außer `SameSite`
**Befund:** Der Sitzungs-Cookie war `SameSite=Lax`, mehr nicht. Das Merkblatt
sagt ausdrücklich, dass das allein nicht genügt: `SameSite` wirkt auf der
Ebene der registrierbaren Domain, nicht des Ursprungs. Besonders heikel war
`POST /api/uploads` — es nimmt `multipart/form-data` entgegen, und das kann
ein Formular auf einer fremden Seite abschicken.
**Behoben:** Jeder schreibende Zugriff (POST, PUT, PATCH, DELETE) wird gegen
den `Origin`-Header geprüft, verglichen mit dem Host der Anfrage — nicht mit
`BASE_URL`, weil die Anwendung absichtlich unter mehreren Domains läuft.
Fehlt der Header ganz, wird durchgelassen: Browser setzen ihn bei fremden
Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar nicht.
### 4. Keine Schutzheader
**Befund:** Die Anwendung setzte keinen einzigen: kein `X-Frame-Options`,
keine `Content-Security-Policy`, kein `Referrer-Policy`,
kein `X-Content-Type-Options`. Die Verwaltung ließ sich also in eine fremde
Seite einbetten und per Clickjacking fernsteuern. Dazu verriet
`X-Powered-By: Next.js`, wonach jemand Schwachstellen suchen müsste.
**Behoben** in `src/proxy.ts`:
| Header | Wert |
| --- | --- |
| `Content-Security-Policy` | `frame-ancestors 'none'` |
| `X-Frame-Options` | `DENY` |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | Kamera, Mikrofon, Standort, Bezahlen aus |
`X-Powered-By` ist abgeschaltet.
**Bewusst nicht gesetzt** ist eine 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 Policy mit `unsafe-inline` sähe nach Schutz
aus, ohne einer zu sein. Siehe [Offen](#offen).
### 5. Sitzungen ohne absolute Obergrenze
**Befund:** Die Sitzung lief 30 Tage nach der letzten Nutzung ab — und wurde
bei jeder Nutzung verlängert. Eine einmal entwendete Sitzung hätte durch
regelmäßige Nutzung **unbegrenzt** weitergelebt.
**Behoben:** Zusätzlich eine harte Grenze ab dem Anmelden. Über die
Verlängerung hinaus wird nie verlängert.
| Einstellung | Voreinstellung | Empfehlung des Merkblatts |
| --- | --- | --- |
| `SESSION_IDLE_DAYS` | 30 Tage | 15–30 **Minuten** bei geringem Risiko |
| `SESSION_MAX_DAYS` | 90 Tage | 4–8 **Stunden** absolut |
Die Voreinstellungen liegen bewusst weit darüber. Die Zahlen des Merkblatts
zielen auf öffentlich erreichbare Anwendungen; bei einem internen Werkzeug,
an dem man täglich arbeitet, wäre eine halbstündige Abmeldung eine Zumutung
und würde nur dazu führen, dass jemand den Browser dauerhaft offen lässt. Wer
strenger fahren will, setzt beide Werte in der `.env` — dafür sind sie
einstellbar.
### 6. `Secure` fehlte hinter einem TLS-Proxy
**Befund:** Das `Secure`-Flag am Cookie hing allein an `BASE_URL`. Wer die
Anwendung hinter einem Proxy mit HTTPS betreibt, aber `BASE_URL` auf `http`
stehen lässt, bekam den Cookie ohne `Secure` — er wäre über eine
unverschlüsselte Verbindung mitgegangen.
**Behoben:** Zusätzlich wird `X-Forwarded-Proto` ausgewertet.
### 7. Die Anmeldung verriet, welche Konten es gibt
**Befund:** Bei falschem Passwort kam immer dieselbe Meldung — richtig. Aber
drei Fälle fielen aus der Reihe und bestätigten damit die Existenz eines
Kontos:
- ein deaktiviertes Konto („Dieses Konto ist deaktiviert“),
- ein Konto aus dem Verzeichnis („meldet sich über den externen Anbieter an“),
- ein gesperrtes Konto (429 mit Wartezeit).
**Behoben** für die ersten beiden:
- Die Prüfung auf „deaktiviert“ steht jetzt **nach** der Passwortprüfung. Wer
bis dahin kommt, hat das Konto nachweislich in der Hand — ihm darf man
sagen, warum es nicht weitergeht.
- Verzeichnis-Konten bekommen die allgemeine Meldung. Der Knopf für den
Anbieter steht daneben.
**Bewusst offen gelassen:** die Sperrmeldung. Sie muss vor der Passwortprüfung
kommen — das ist ihr Zweck. Wer sie versteckt, lässt einen rechtmäßigen
Benutzer 15 Minuten im Dunkeln stehen. Bei einem Werkzeug, in dem die
E-Mail-Adressen der Kollegen ohnehin bekannt sind, wiegt die Verständlichkeit
schwerer als ein Hinweis, den ein Angreifer auch anders bekommt.
### 8. Kontosperre schützte nicht gegen breites Durchprobieren
**Befund:** Nach 8 Fehlversuchen wurde ein **Konto** 15 Minuten gesperrt. Wer
stattdessen ein Passwort gegen viele Konten probiert, löst diese Sperre nie
aus.
**Behoben:** zusätzlich eine Bremse je Absender — 20 Versuche in 5 Minuten,
danach 429. Im Arbeitsspeicher und damit pro Container; die Adresse dient nur
als Schlüssel und wird nirgends gespeichert.
### 9. Ersteinrichtung mit Wettlauf
**Befund:** Zwischen „gibt es schon einen Benutzer?“ und dem Anlegen lag eine
Lücke. Zwei gleichzeitige Aufrufe auf einer frischen Installation hätten
**zwei** Administratoren erzeugt — einer davon ungebeten.
**Behoben:** Prüfung und Anlegen laufen in einer serialisierbaren
Transaktion. Ein Test schickt drei Anfragen gleichzeitig; genau eine kommt
durch.
### 10. Abgelaufene Sitzungen blieben liegen
**Befund:** `pruneExpiredSessions()` existierte, wurde aber nirgends
aufgerufen. Kein Sicherheitsproblem — abgelaufene Sitzungen werden bei der
Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
wird beiläufig aufgeräumt.
---
## Was schon vorher stimmte
| Anforderung | Umsetzung |
| --- | --- |
| Sitzungs-Token aus einem CSPRNG, ≥ 128 Bit | 32 Byte aus `randomBytes` |
| Token nicht im Klartext gespeichert | in der Datenbank nur der SHA-256 |
| `HttpOnly` | gesetzt |
| `SameSite` | `Lax` |
| Pfad so eng wie möglich | `/` — die Anwendung liegt an der Wurzel |
| Neue Sitzung beim Anmelden | immer ein frisches Token; keine Sitzungsfixierung möglich |
| Alle Sitzungen beenden beim Passwortwechsel | ja, danach sofort eine neue |
| Sitzung entwerten beim Deaktivieren | bei jedem Zugriff wird `isActive` geprüft |
| Passwortvergleich in konstanter Zeit | `timingSafeEqual` |
| Gleiche Antwortzeit für unbekannte Adressen | Vergleich gegen einen Attrappen-Hash mit denselben Parametern |
| Berechtigungen an einer Stelle | `src/lib/auth/policy.ts`; jede Route fragt sie |
| Serverseitige Prüfung, nicht nur ausgeblendete Knöpfe | ja |
Zur **Anmeldung über OIDC** (siehe [authentik.md](authentik.md)): PKCE mit
S256, `state` gegen CSRF, `nonce` gegen wiedereingereichte Token, Prüfung des
ID-Tokens gegen den JWKS des Anbieters samt Aussteller, Empfänger und Ablauf.
Die Rückkehradresse ist fest verdrahtet — es gibt keinen Parameter, über den
sich eine offene Weiterleitung bauen ließe. Verknüpft wird ein bestehendes
Konto nur bei **bestätigter** E-Mail-Adresse.
---
## Offen
Bewusst nicht umgesetzt, mit Begründung.
**Vollständige Content-Security-Policy für Skripte.** Bräuchte Nonces durch
die ganze Anwendung, weil Next.js eingebettete Skripte ausliefert. Der Nutzen
wäre echt — es ist die zweite Verteidigungslinie gegen XSS —, aber halb
umgesetzt ist sie wertlos. Gehört als eigenes Vorhaben auf die
[Roadmap](roadmap.md), nicht nebenbei erledigt.
**`__Host-` als Präfix des Cookie-Namens.** Das Merkblatt nennt es die
stärkste Variante. Es setzt `Secure` voraus — der Name müsste sich also je
nach Betriebsart ändern, und beim Umstellen von HTTP auf HTTPS würden alle
Sitzungen still ungültig. Für eine Anwendung, die auch im LAN über HTTP läuft,
ist das mehr Verwirrung als Gewinn. Unter reinem HTTPS wäre es eine sinnvolle
Ergänzung.
**Abgleich gegen bekannt gewordene Passwörter.** Eine Prüfung gegen die
Have-I-Been-Pwned-Liste (k-Anonymität, ohne das Passwort zu übertragen) wäre
der wirksamste einzelne Schritt für die Passwortqualität. Sie setzt aber
voraus, dass die Anwendung ins Internet telefonieren darf — bei einer selbst
gehosteten Installation nicht selbstverständlich. Sinnvoll als abschaltbare
Option.
**Zweiter Faktor.** Nicht vorhanden. Wer ihn braucht, bekommt ihn heute über
den Umweg Authentik: Dort lässt sich TOTP oder WebAuthn erzwingen, und diese
Anwendung übernimmt nur das Ergebnis. Das ist die bessere Arbeitsteilung als
eine eigene halbgare Umsetzung.
**Protokollierung sicherheitsrelevanter Ereignisse.** Fehlversuche, Sperren
und Rechteänderungen werden nicht eigens protokolliert. Für den Betrieb im
Team verzichtbar; bei mehr Konten wäre es das Erste, was fehlt.
**Verteilte Bremse.** Die Zähler für Anmeldeversuche und Rückmeldungen liegen
im Arbeitsspeicher und gelten damit je Container. Bei mehreren Instanzen
hinter einem Lastverteiler bräuchte es einen gemeinsamen Speicher.
---
## Nachgeprüft
Die Härtungen sind durch Tests belegt (21 Prüfungen), unter anderem:
- Alle fünf Schutzheader sind an jeder Antwort vorhanden, `X-Powered-By` ist
weg.
- Ein schreibender Zugriff mit fremdem `Origin` wird abgewiesen — auch beim
Upload; mit eigenem oder ohne `Origin` geht er durch.
- Hochgeladene Dateien behalten ihre eigene, schärfere Sandbox-Regel.
- Ein 5000 Zeichen langes Passwort wird abgewiesen, bevor gerechnet wird.
- Drei gleichzeitige Ersteinrichtungen erzeugen genau einen Administrator.
- Eine unbekannte Adresse ist an der Antwort nicht von einem falschen
Passwort zu unterscheiden — auch nicht bei einem Verzeichnis-Konto.
- Der Sitzungs-Cookie trägt `HttpOnly`, `SameSite=Lax`, `Path=/` und ein
ausreichend langes Token; `Secure` erscheint nur unter HTTPS.
+4
View File
@@ -9,6 +9,10 @@ const nextConfig: NextConfig = {
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
// sollen nicht vom Bundler verarbeitet werden.
serverExternalPackages: ["qrcode", "pg"],
// „X-Powered-By: Next.js" verrät nur, wonach jemand Schwachstellen suchen
// müsste. Die Schutzheader setzt src/middleware.ts.
poweredByHeader: false,
};
export default nextConfig;
@@ -0,0 +1,9 @@
-- Harte Obergrenze für Sitzungen, unabhängig von der Aktivität.
--
-- Bestehende Sitzungen bekommen 90 Tage ab jetzt — sie sollen durch die
-- Änderung nicht plötzlich alle ungültig werden.
ALTER TABLE "Session"
ADD COLUMN "absoluteExpiresAt" TIMESTAMP(3) NOT NULL
DEFAULT (CURRENT_TIMESTAMP + INTERVAL '90 days');
CREATE INDEX "Session_absoluteExpiresAt_idx" ON "Session"("absoluteExpiresAt");
+5
View File
@@ -63,12 +63,17 @@ model Session {
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
expiresAt DateTime
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([absoluteExpiresAt])
}
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
+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.
+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 });
}
+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).*)"],
};