diff --git a/.env.example b/.env.example index 9e43a79..7161f6e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index b1f4649..098ccee 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docker-compose.yml b/docker-compose.yml index a1de962..18c4ecc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/docs/sicherheit.md b/docs/sicherheit.md new file mode 100644 index 0000000..f527be2 --- /dev/null +++ b/docs/sicherheit.md @@ -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. diff --git a/next.config.ts b/next.config.ts index 2f62a3b..ca0802f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/prisma/migrations/20260815100000_sitzung_absolute_grenze/migration.sql b/prisma/migrations/20260815100000_sitzung_absolute_grenze/migration.sql new file mode 100644 index 0000000..42501c8 --- /dev/null +++ b/prisma/migrations/20260815100000_sitzung_absolute_grenze/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3dd8599..9fb8253 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 89af8de..6de5e70 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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(), diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 097ce7f..baf43f3 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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), }); diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index ecfdae7..acc0bbc 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -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 { 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, + absender: string, ): Promise { + // 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 }); } diff --git a/src/app/api/uploads/[id]/route.ts b/src/app/api/uploads/[id]/route.ts index a8075e7..f088b9c 100644 --- a/src/app/api/uploads/[id]/route.ts +++ b/src/app/api/uploads/[id]/route.ts @@ -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", diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 444c5a7..e9fa175 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -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 { 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. diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 761fccd..b28dd9a 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -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 { const salt = randomBytes(PARAMS.saltLength); @@ -48,8 +55,8 @@ export async function hashPassword(password: string): Promise { 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.`; } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 637039a..f97c5f8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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 { + 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 { + // 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 { 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 { 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 { // 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 { diff --git a/src/lib/auth/throttle.ts b/src/lib/auth/throttle.ts new file mode 100644 index 0000000..0c46d45 --- /dev/null +++ b/src/lib/auth/throttle.ts @@ -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(); + +/** 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" + ); +} diff --git a/src/lib/http.ts b/src/lib/http.ts index 46d3e69..34a3b52 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -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 }); } diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..dd70b77 --- /dev/null +++ b/src/proxy.ts @@ -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 = { + // 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).*)"], +};