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
This commit is contained in:
Florian Wilde
2026-08-24 01:02:03 +02:00
parent 4e2d222e86
commit 1e0d04d0db
8 changed files with 180 additions and 22 deletions
+112 -1
View File
@@ -1,7 +1,9 @@
# 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.
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:
@@ -163,6 +165,72 @@ 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
@@ -218,6 +286,49 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
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