Die Frage kam auf und die Antwort gehoert festgehalten, sonst stellt sie sich in einem halben Jahr wieder — dann ohne die Begruendung. Kurz: Es gibt keine Selbstregistrierung. Konten legt ein Administrator an, der die Adresse selbst eintraegt; eine Bestaetigungsmail wuerde also niemanden hereinlassen und niemanden fernhalten. Und weil es keinen Reset per E-Mail gibt, ist die unbestaetigte Adresse auch kein Uebernahmeweg. Noetig wuerde sie in dem Moment, in dem sich Leute selbst registrieren duerfen. Dazu das Zeitfenster der Ersteinrichtung ausdruecklich benannt: Das erste Konto entsteht ohne Legitimation und ist gleich Administrator. Dagegen hilft keine Verifizierung, sondern nur die Reihenfolge beim Aufsetzen — oder ein einmaliges Token, das jetzt auf der Roadmap steht.
286 lines
13 KiB
Markdown
286 lines
13 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.
|
||
|
||
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||
Administrator an, der die Adresse selbst einträgt. Eine Bestätigungsmail
|
||
würde also belegen, dass eine Adresse existiert, aber niemanden hereinlassen
|
||
und niemanden fernhalten.
|
||
|
||
Dazu kommt: Es gibt **kein „Passwort vergessen" per E-Mail**. Zurücksetzen
|
||
kann nur ein Administrator unter Verwaltung → Benutzer. Damit ist die
|
||
unbestätigte Adresse auch kein Übernahmeweg — bei einem Reset per Mail wäre
|
||
sie einer. Die Adresse ist hier schlicht ein Anmeldename.
|
||
|
||
Nötig würde die Verifizierung in dem Moment, in dem sich Leute selbst
|
||
registrieren dürfen. Dann allerdings zwingend, zusammen mit einem
|
||
SMTP-Versand, Ablauffristen für die Bestätigungslinks und einem Weg für
|
||
nicht zugestellte Mails.
|
||
|
||
Zwei Stellen bleiben davon ohnehin unberührt, weil dort nicht der
|
||
Administrator die Adresse einträgt:
|
||
|
||
- **Die Ersteinrichtung.** Wer zuerst kommt, wird Administrator. Dagegen hilft
|
||
keine Verifizierung, sondern nur, das Fenster zu schließen — siehe unten.
|
||
- **Die Anmeldung über einen Anbieter.** Dort bürgt der Anbieter für die
|
||
Adresse, und genau das prüft die Anwendung: Ein bestehendes Konto wird nur
|
||
bei `email_verified` verknüpft.
|
||
|
||
**Das Zeitfenster der Ersteinrichtung.** Solange es keinen Benutzer gibt und
|
||
die Anmeldung eingeschaltet ist, wird das erste Konto ohne jede Legitimation
|
||
angelegt — und ist Administrator der Installation. Steht die Anwendung schon
|
||
öffentlich, bevor der Betreiber sein Konto anlegt, kann ein Fremder sie
|
||
übernehmen.
|
||
|
||
Heute ist das eine Frage der Reihenfolge: erst starten, sofort das erste
|
||
Konto anlegen, dann erreichbar machen. Sauberer wäre ein einmaliges Token,
|
||
das beim ersten Start ins Protokoll geschrieben wird und für die
|
||
Ersteinrichtung nötig ist. Steht als Vorschlag in der
|
||
[Roadmap](roadmap.md).
|
||
|
||
**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.
|