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
248 lines
11 KiB
Markdown
248 lines
11 KiB
Markdown
# 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.
|