# 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, ergänzt am 24.08.2026 um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei offenen Punkte am Ende). 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. ### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt **Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das prüfte der Code auch, nur war die Bestätigung falsch berechnet: ```ts emailVerified: payload.email_verified !== false ``` Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der `email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —, hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines Administrators. **Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die Verknüpfung unterbleibt. **Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und Nonce bestehen. ### 12. Eine fehlende Angabe schaltete die Anmeldung ab **Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß „aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die Compose-Vorgabe stand zusätzlich auf `false`. **Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false` setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml` und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der Container beim Start eine mehrzeilige Warnung ins Protokoll. Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt. ### 13. Wettlauf beim ersten Administrator über OIDC **Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion. **Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer Transaktion mit `isolationLevel: "Serializable"`. ### 14. Die Herkunftsprüfung verglich das Schema nicht **Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt damit als eigen, obwohl die Anwendung unter `https://example.com` läuft. **Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. **Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw. `X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr. ### 15. Die Kontosperre konnte den Besitzer aussperren **Befund:** Acht Fehlversuche sperrten ein Konto für 15 Minuten — auch gegen das **richtige** Passwort. Wer die Adresse eines Administrators kannte, konnte die Sperre alle 15 Minuten erneuern und die Verwaltung praktisch dauerhaft schließen. Ein Nachweis, dass der Anfragende der Kontoinhaber ist, war dafür nicht nötig. **Behoben:** Aus der Sperre wurde eine **Wartezeit**. Die ersten vier Fehlversuche bleiben folgenlos, danach verdoppelt sie sich bis zu einer Minute: | Fehlversuche | Wartezeit | | --- | --- | | bis 4 | keine | | 5 | 1 s | | 7 | 4 s | | 10 | 32 s | | ab 11 | 60 s | Für den Besitzer heißt das im schlimmsten Fall eine Minute statt einer Viertelstunde Aussperrung. Für jemanden, der Passwörter durchprobiert, einen Versuch pro Minute — das ist die eigentliche Aufgabe der Bremse, und die erfüllt sie weiterhin. Die Wartezeit wird **vor** der Passwortprüfung ausgewertet. Ein Versuch, der ohnehin abgewiesen wird, kostet damit keine 128 MiB. ### 16. Die Bremsen ließen sich über eine Kopfzeile umgehen **Befund:** Der Absender wurde aus `X-Forwarded-For` gelesen. Ohne vorgeschalteten Proxy — und die mitgelieferte Compose-Konfiguration veröffentlicht den Port direkt — wählt ihn jeder Aufrufer selbst und umgeht damit Login- wie Feedback-Bremse. **Behoben:** Drei Stufen statt einer, in `src/lib/auth/throttle.ts`: 1. **Je Absender** — nur noch, wenn `TRUST_PROXY=true` gesetzt ist. Ohne diese Angabe wird die Kopfzeile gar nicht gelesen. Eine Bremse, die sich mit einem selbst gewählten Wert umgehen lässt, ist keine; dann lieber ehrlich keine. 2. **Je Konto** — die Wartezeit aus Punkt 15. Sie hängt an der Kennung des Kontos, nicht an einer Kopfzeile, und ist deshalb nicht zu umgehen. 3. **Für die ganze Installation** — eine Obergrenze von 60 Passwortprüfungen je Minute, und 120 Rückmeldungen je Minute. Das ist die Stufe, die den Speicher schützt: Ohne sie könnte jemand mit genügend gleichzeitigen Anfragen den Server allein über die Hash-Berechnung lahmlegen. Was bleibt: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei mehreren Instanzen bräuchte es einen gemeinsamen Zähler — siehe „Offen“. ### 17. Skripte liefen ohne Content-Security-Policy **Befund:** Die Policy bestand aus `frame-ancestors 'none'`. Gegen eingeschleusten Skriptcode wirkte sie nicht. **Behoben:** Eine vollständige Policy mit **Nonce je Anfrage**. Skripte laufen nur, wenn sie den Nonce dieser einen Anfrage tragen oder von einem Skript geladen wurden, das selbst einen hatte (`strict-dynamic`). Ein Skript, das über eine Lücke in die Seite gerät, hat keinen — und läuft nicht. Dazu `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`. `frame-src` nennt ausschließlich die beiden Video-Anbieter, die der Typ „Video“ einbetten kann. **Zwei bewusste Lockerungen**, damit sie nicht als Versehen gelesen werden: - `style-src` erlaubt weiterhin `'unsafe-inline'`. Ein Nonce wirkt nur auf `