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
420 lines
20 KiB
Markdown
420 lines
20 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, 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.
|