Files
qrcode-generator/docs/sicherheit.md
T
Florian Wilde 1e0d04d0db Befunde der Sicherheitspruefung abarbeiten
S1 (hoch): Die OIDC-Rueckkehr verknuepft eine Identitaet mit einem
bestehenden Konto nur bei bestaetigter E-Mail — geprueft wurde das auch,
nur war die Bestaetigung als `email_verified !== false` berechnet. Damit
galt auch ein fehlender Claim als bestaetigt, und genau den schicken
manche Anbieter gar nicht. Jetzt zaehlt allein ein ausdrueckliches `true`.

S2 (hoch): `isAuthEnabled()` verlangte den Wert "true"; alles andere hiess
"aus". Eine vergessene Variable ergab eine Anwendung, in der jeder
Besucher alles darf, und nichts sah danach aus. Umgedreht: Abgeschaltet
wird nur, wer ausdruecklich "false" setzt. Die Vorgaben in Compose und
.env.example stehen auf true, und der Container warnt beim Start
mehrzeilig, solange die Anmeldung aus ist.

S5 (mittel): Der OIDC-Bootstrap las `user.count()` und legte danach an —
zwei gleichzeitige erste Anmeldungen konnten beide Administrator werden.
Jetzt in einer serialisierbaren Transaktion, wie die lokale
Ersteinrichtung es laengst tut.

S6 (niedrig): Die Herkunftspruefung verglich nur den Host, nicht das
Schema. Das Schema kommt jetzt aus BASE_URL — die eine Angabe zum
Ursprung, ueber die der Aufrufer nicht mitbestimmt.

S3, S4 und S7 bleiben offen und stehen begruendet in docs/sicherheit.md.
Bei S3 wurde der naheliegende Ausweg geprueft und verworfen: Waehrend der
Sperre weiter Passwoerter zu pruefen kostet 128 MiB je Versuch und
tauschte eine Sperre gegen ein Konto gegen eine gegen alle.

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

420 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## 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.
---
## 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 Kontosperre kann den Besitzer aussperren.** Acht falsche Passwörter
sperren ein Konto für 15 Minuten, ohne dass der Anfragende belegen muss, wer
er ist. Wer die Adresse des Administrators kennt, kann die Sperre alle 15
Minuten erneuern und die Verwaltung praktisch dauerhaft blockieren.
Der naheliegende Ausweg — auch während der Sperre das Passwort prüfen und ein
richtiges durchlassen — wurde geprüft und **verworfen**: Eine Prüfung kostet
hier rund **128 MiB Arbeitsspeicher** (scrypt mit N = 2¹⁷). Wer sie beliebig
oft auslösen darf, legt damit den Server lahm. Die Sperre gegen ein Konto
einzutauschen wäre also eine Sperre gegen alle.
Was es stattdessen braucht, ist ein eigener Entwurf: eine ansteigende
Verzögerung, die keinen Speicher hält, oder eine billige Vorprüfung vor dem
teuren Hash — zusammen mit dem nächsten Punkt zu entscheiden.
**Die Bremsen lassen sich über `X-Forwarded-For` umgehen.** Der Absender wird
aus diesem Header gelesen. Steht kein Proxy davor — und die mitgelieferte
Compose-Konfiguration veröffentlicht den Port direkt —, wählt ihn jeder
Aufrufer selbst und umgeht damit Login-Bremse und Feedback-Bremse. Die Zähler
liegen zudem im Arbeitsspeicher eines Prozesses und sind nach einem Neustart
weg.
Die Anwendung läuft in Next.js-Routen und kommt an die Adresse der
Gegenstelle gar nicht heran — „im Zweifel die Socket-Adresse nehmen“ ist hier
also keine Möglichkeit. Nötig ist eine ausdrückliche Einstellung, welchem
Proxy zu trauen ist, und ein gemeinsamer Zähler, wenn mehr als eine Instanz
läuft. Beides gehört mit dem Punkt davor in einen Entwurf und nicht in eine
schnelle Änderung.
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/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](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.