Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse Proxy und Zertifikat liegen ausserhalb dieser Anwendung. Fehlt dort etwas, merkt man es am gedruckten Code, der nicht aufgeht. Der Knopf "Pruefen" geht den Weg der Scanner nach: Namen aufloesen, Zertifikat ansehen, Adresse aufrufen — und sich beweisen lassen, dass am anderen Ende diese Installation steht. Der Beweis ist ein HMAC ueber eine frische Zufallszahl; das Geheimnis dafuer entsteht beim ersten Check und verlaesst den Server nie. Geprueft wird nur ein Hostname aus der Datenbank, nie einer aus der Anfrage — sonst waere die Route ein Werkzeug zum Abklopfen fremder Adressen. Scheitert schon DNS, fallen die drei Folgeschritte aus, statt dreimal dasselbe zu melden. Die Grenze der Aussage steht im Bericht selbst: Der Aufruf startet dort, wo der Container laeuft. Refs #7
14 KiB
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
- Session Management Cheat Sheet
- Cross-Site Request Forgery Prevention Cheat Sheet
- Authentication Cheat Sheet
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.
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): 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.
Der Domain-Check und seine Grenzen
Auf der Domain-Seite ruft der Knopf Prüfen die eingetragene Domain vom Server aus auf. Drei Dinge grenzen das ein:
- Geprüft wird ausschließlich ein Hostname aus der Datenbank, nie einer aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus beliebige Adressen abklopfen ließen.
- Auslösen darf sie nur, wer
instance:managehat — dieselbe Berechtigung, die auch Domains anlegt. - Die Route, die den Beweis liefert (
/api/domain-check), ist ohne Anmeldung erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
Beim Zertifikat läuft die Verbindung bewusst mit rejectUnauthorized: false.
Nur so lässt sich ein schlechtes Zertifikat beschreiben — abgelaufen,
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Übertragen wird über diese Verbindung nichts.
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_verifiedverknü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.
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, 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-Byist weg. - Ein schreibender Zugriff mit fremdem
Originwird abgewiesen — auch beim Upload; mit eigenem oder ohneOrigingeht 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;Secureerscheint nur unter HTTPS.