Files
qrcode-generator/docs/sicherheit.md
T
Florian Wilde 367d9c540b Bremsen, Sperren und CSP: die offenen Punkte schliessen
S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das
richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte
ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche
folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der
Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt
einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung,
damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet.

S4: Drei Stufen statt einer. Die Bremse je Absender liest
X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert
jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung.
Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine
Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120
Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht
ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher.

CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic,
default-src self, object-src none, base-uri und form-action self. Skripte
ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben
und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil
ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil
der Typ Video ausdruecklich Direktlinks annimmt.

Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe —
Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von
Administratorrechten. Ohne Passwoerter, Token und Adressen.

Refs #20
2026-08-24 01:43:00 +02:00

23 KiB
Raw Blame History

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:

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.

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:

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 <style>-Blöcke, nicht auf style="…" an einzelnen Elementen; die Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen. Über ein Stilattribut lässt sich kein Code ausführen, der Gewinn wäre gering.
  • media-src erlaubt jede https:-Quelle. Der Typ „Video“ nimmt ausdrücklich auch einen Direktlink auf eine Videodatei entgegen, und wo die liegt, weiß nur der, der ihn einträgt.

upgrade-insecure-requests steht nur, wenn BASE_URL auf https zeigt — auf einer bewusst über HTTP betriebenen Installation würde die Regel die eigenen Ressourcen hochstufen und alles zerlegen.

18. Sicherheitsereignisse standen nirgends

Befund: Fehlversuche, OIDC-Verknüpfungen und die Vergabe von Administratorrechten hinterließen keine Spur.

Behoben: src/lib/auth/protokoll.ts schreibt je Ereignis eine Zeile als JSON auf die Standardausgabe — damit landet sie in docker compose logs und lässt sich von dort weiterreichen, ohne dass die Anwendung ein Werkzeug dafür kennen muss.

Protokolliert werden: fehlgeschlagene Anmeldungen samt Zahl der Versuche und Wartezeit, abgelehnte OIDC-Verknüpfungen mit Grund, erfolgte Verknüpfungen und jede Vergabe von Administratorrechten.

Was ausdrücklich nicht hineingehört: Passwörter, Sitzungstoken und vollständige E-Mail-Adressen. Konten stehen mit ihrer Id da. Ein Protokoll wird kopiert und verschickt und ist selten so gut geschützt wie die Datenbank.


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:manage hat — 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.

Die Zähler gelten je Prozess. Bremsen und Wartezeiten liegen im Arbeitsspeicher und sind nach einem Neustart weg. Bei einer Instanz — dem Regelfall dieser Anwendung — ist das verschmerzbar: Die Wartezeit je Konto steht in der Datenbank und übersteht den Neustart, nur die Zähler je Absender und die Obergrenze der Instanz beginnen von vorn.

Wer mehrere Instanzen hinter einem Lastverteiler betreibt, braucht einen gemeinsamen, atomaren Zähler — üblicherweise Redis. Das ist ein weiterer Dienst und lohnt sich erst, wenn es die zweite Instanz wirklich gibt.

Prisma hängt an einer Advisory. npm audit --omit=dev meldet GHSA-ggr8-5vv4-36mx in deepmerge-ts@7.1.5, mitgeschleppt über @prisma/config. Es geht um Stapelüberlauf beim Zusammenführen rekursiver Objekte.

Aus HTTP-Eingaben entsteht hier keine Prisma-Konfiguration, die praktische Ausnutzbarkeit ist also gering. Die Prisma-CLI liegt aber im Laufzeit-Abbild und läuft bei jedem Containerstart für die Migrationen — deshalb steht der Punkt hier und nicht unter „egal“. Eine Fassung ohne die Advisory gibt es noch nicht: 7.9.1 ist die neueste und hängt weiterhin an deepmerge-ts@7.1.5. npm audit fix --force würde auf Prisma 6 zurückstufen und ist deshalb keine Lösung. Zu tun: beobachten und aktualisieren, sobald es eine passende Fassung gibt.

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.

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-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.