Fix #20: Befunde der Sicherheitsprüfung #21

Merged
florian.wilde merged 2 commits from fix/20-sicherheitspruefung into main 2026-08-24 07:41:08 +00:00
Owner

Alle sieben Befunde am Code nachgeprüft — jeder trifft zu. Sechs sind behoben, dazu zwei Punkte der Härtungsliste. Einer bleibt offen, weil es dafür noch keine Fassung gibt.

Befund Stand
S1 OIDC wertet unbestätigte E-Mail als bestätigt behoben
S2 Anmeldung standardmäßig aus behoben
S3 Kontosperre erlaubt gezieltes Aussperren behoben
S4 Bremsen über X-Forwarded-For umgehbar behoben
S5 Wettlauf beim ersten OIDC-Administrator behoben
S6 Herkunftsprüfung ohne Schema behoben
S7 Advisory in der Prisma-Toolchain offen — keine Fassung verfügbar
Härtung Nonce-basierte CSP umgesetzt
Härtung Sicherheitsereignisse protokollieren umgesetzt

S1 — der schwerste, und er war real

Der Code prüfte durchaus auf eine bestätigte Adresse, bevor er eine fremde Identität an ein bestehendes Konto hängt. Nur war die Bestätigung falsch berechnet:

emailVerified: payload.email_verified !== false   // vorher
emailVerified: payload.email_verified === true    // jetzt

Ein Anbieter, der den Claim gar nicht mitschickt — erlaubt und nicht selten —, hätte die Verknüpfung geöffnet. Der Kommentar daneben beschrieb die Absicht korrekt; die Zeile darunter tat das Gegenteil.

S2 — tiefer als gemeldet

Die Prüfung nannte die Compose-Vorgabe. Der eigentliche Fehler saß in isAuthEnabled(): Sie verlangte den Wert "true", alles andere hieß „aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine Anwendung ohne jede Anmeldung — und nichts sah danach aus.

Umgedreht: abgeschaltet wird nur auf ausdrückliches false. Vorgaben in docker-compose.yml und .env.example stehen auf true, und der Container schreibt beim Start eine mehrzeilige Warnung ins Protokoll, solange die Anmeldung aus ist.

Der offene Betrieb bleibt möglich — er ist dokumentiert und für Einzelplatz und LAN gedacht. Er ist nur nicht mehr das, was man versehentlich bekommt.

S3 — aus der Sperre 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.

Fehlversuche Wartezeit
bis 4 keine
5 1 s
7 4 s
10 32 s
ab 11 60 s

Der Besitzer kommt spätestens nach einer Minute hinein, ein Fremder bekommt einen Versuch pro Minute. Ausgewertet wird die Wartezeit vor der Passwortprüfung — ein ohnehin abgewiesener Versuch kostet damit keine 128 MiB.

S4 — drei Stufen statt einer

  1. Je Absender: liest X-Forwarded-For nur noch mit TRUST_PROXY=true. Ohne Proxy wählt den Wert jeder Aufrufer selbst; eine so umgehbare Bremse ist eine Behauptung.
  2. Je Konto: die Wartezeit oben. Hängt an der Datenbank, nicht an einer Kopfzeile.
  3. Für die Installation: 60 Passwortprüfungen und 120 Rückmeldungen je Minute. Die einzige Stufe, die sich nicht über Kopfzeilen umgehen lässt — und die, die den Speicher schützt.

S6 — Schema statt nur Host

http://example.com galt als eigene Herkunft, obwohl die Anwendung unter https://example.com läuft. Das Schema wird jetzt mitverglichen und stammt aus BASE_URL.

Zur Einordnung: Aus einem Browser war das nicht auszunutzen. Host darf Skriptcode nicht setzen, und ein selbst gesetztes X-Forwarded-Host erzwingt einen CORS-Vorabflug, den die Anwendung nicht beantwortet.

CSP mit Nonce

Vollständige Policy statt nur frame-ancestors: frischer Nonce je Anfrage, strict-dynamic, default-src 'self', object-src 'none', base-uri/form-action auf 'self'. Ein eingeschleustes Skript hat keinen Nonce und läuft nicht.

Zwei Lockerungen sind bewusst und stehen begründet in docs/sicherheit.md:

  • style-src erlaubt weiter 'unsafe-inline' — ein Nonce deckt keine style="…"-Attribute, und über eines lässt sich kein Code ausführen.
  • media-src erlaubt https: — der Typ „Video“ nimmt ausdrücklich Direktlinks auf Videodateien an. Der erste, strengere Versuch hat genau das kaputtgemacht; der Test hat es gefangen.

Protokoll

src/lib/auth/protokoll.ts schreibt je Ereignis eine JSON-Zeile auf die Standardausgabe: Fehlversuche mit Zahl und Wartezeit, abgelehnte und erfolgte OIDC-Verknüpfungen, jede Vergabe von Administratorrechten. Ohne Passwörter, Token und Adressen — Konten stehen mit ihrer Id da.

Was offen bleibt

S7. Es gibt keine Prisma-Fassung ohne die Advisory: 7.9.1 ist die neueste und hängt weiter an deepmerge-ts@7.1.5. npm audit fix --force stufte auf Prisma 6 zurück.

Dazu in docs/sicherheit.md unter „Offen“: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei einer Instanz — dem Regelfall — ist das verschmerzbar; die Wartezeit je Konto steht in der Datenbank und übersteht den Neustart. Mehrere Instanzen bräuchten einen gemeinsamen Zähler.

Neue Einstellung

TRUST_PROXY (Vorgabe false) in .env.example, docker-compose.yml und README.

Geprüft

  • Neue Reihe zum neuen Verhalten: 45 von 45 — darunter live nachgestellt, dass fünf Fehlversuche in eine Wartezeit von einer Sekunde münden und der Besitzer danach hineinkommt.
  • Reihe zu den Befunden: 35 von 35, mit den Wahrheitstabellen für email_verified und AUTH_ENABLED.
  • Am laufenden Server: mit leerem AUTH_ENABLED antwortet / mit 307 auf /anmelden, /api/codes mit 401.
  • 19 bestehende Reihen gegen den fertig gebauten Container, alle grün — e2e 79, browser 39, browser-types 25, filter-art 28, dubletten 24, logo 44, domain-check 38, hilfe-domains 24, typen-gruppen 38, landing 31, landing-social-video 31, codes-view 33, darkmode 38, kurzlinks 57, statisch 38, uploads 48, branding 42, version 20; dazu auth 63, codes-groups 9, slug-rechte 29 und sicherheit 21 gegen eine frische Instanz mit Anmeldung.
  • Typprüfung, Linter und Changelog-Prüfung grün.

Closes #20

Alle sieben Befunde am Code nachgeprüft — **jeder trifft zu**. Sechs sind behoben, dazu zwei Punkte der Härtungsliste. Einer bleibt offen, weil es dafür noch keine Fassung gibt. | | Befund | Stand | | --- | --- | --- | | S1 | OIDC wertet unbestätigte E-Mail als bestätigt | **behoben** | | S2 | Anmeldung standardmäßig aus | **behoben** | | S3 | Kontosperre erlaubt gezieltes Aussperren | **behoben** | | S4 | Bremsen über `X-Forwarded-For` umgehbar | **behoben** | | S5 | Wettlauf beim ersten OIDC-Administrator | **behoben** | | S6 | Herkunftsprüfung ohne Schema | **behoben** | | S7 | Advisory in der Prisma-Toolchain | offen — keine Fassung verfügbar | | Härtung | Nonce-basierte CSP | **umgesetzt** | | Härtung | Sicherheitsereignisse protokollieren | **umgesetzt** | ## S1 — der schwerste, und er war real Der Code prüfte durchaus auf eine bestätigte Adresse, bevor er eine fremde Identität an ein bestehendes Konto hängt. Nur war die Bestätigung falsch berechnet: ```ts emailVerified: payload.email_verified !== false // vorher emailVerified: payload.email_verified === true // jetzt ``` Ein Anbieter, der den Claim gar nicht mitschickt — erlaubt und nicht selten —, hätte die Verknüpfung geöffnet. Der Kommentar daneben beschrieb die Absicht korrekt; die Zeile darunter tat das Gegenteil. ## S2 — tiefer als gemeldet Die Prüfung nannte die Compose-Vorgabe. Der eigentliche Fehler saß in `isAuthEnabled()`: Sie verlangte den Wert `"true"`, alles andere hieß „aus“. Eine **vergessene oder falsch geschriebene** Variable ergab damit eine Anwendung ohne jede Anmeldung — und nichts sah danach aus. Umgedreht: abgeschaltet wird nur auf ausdrückliches `false`. Vorgaben in `docker-compose.yml` und `.env.example` stehen auf `true`, und der Container schreibt beim Start eine mehrzeilige Warnung ins Protokoll, solange die Anmeldung aus ist. Der offene Betrieb bleibt möglich — er ist dokumentiert und für Einzelplatz und LAN gedacht. Er ist nur nicht mehr das, was man versehentlich bekommt. ## S3 — aus der Sperre 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. | Fehlversuche | Wartezeit | | --- | --- | | bis 4 | keine | | 5 | 1 s | | 7 | 4 s | | 10 | 32 s | | ab 11 | 60 s | Der Besitzer kommt spätestens nach einer Minute hinein, ein Fremder bekommt einen Versuch pro Minute. Ausgewertet wird die Wartezeit **vor** der Passwortprüfung — ein ohnehin abgewiesener Versuch kostet damit keine 128 MiB. ## S4 — drei Stufen statt einer 1. **Je Absender:** liest `X-Forwarded-For` nur noch mit `TRUST_PROXY=true`. Ohne Proxy wählt den Wert jeder Aufrufer selbst; eine so umgehbare Bremse ist eine Behauptung. 2. **Je Konto:** die Wartezeit oben. Hängt an der Datenbank, nicht an einer Kopfzeile. 3. **Für die Installation:** 60 Passwortprüfungen und 120 Rückmeldungen je Minute. Die einzige Stufe, die sich nicht über Kopfzeilen umgehen lässt — und die, die den Speicher schützt. ## S6 — Schema statt nur Host `http://example.com` galt als eigene Herkunft, obwohl die Anwendung unter `https://example.com` läuft. Das Schema wird jetzt mitverglichen und stammt aus `BASE_URL`. Zur Einordnung: Aus einem Browser war das nicht auszunutzen. `Host` darf Skriptcode nicht setzen, und ein selbst gesetztes `X-Forwarded-Host` erzwingt einen CORS-Vorabflug, den die Anwendung nicht beantwortet. ## CSP mit Nonce Vollständige Policy statt nur `frame-ancestors`: frischer Nonce je Anfrage, `strict-dynamic`, `default-src 'self'`, `object-src 'none'`, `base-uri`/`form-action` auf `'self'`. Ein eingeschleustes Skript hat keinen Nonce und läuft nicht. Zwei Lockerungen sind bewusst und stehen begründet in `docs/sicherheit.md`: - `style-src` erlaubt weiter `'unsafe-inline'` — ein Nonce deckt keine `style="…"`-Attribute, und über eines lässt sich kein Code ausführen. - `media-src` erlaubt `https:` — der Typ „Video“ nimmt ausdrücklich Direktlinks auf Videodateien an. Der erste, strengere Versuch hat genau das kaputtgemacht; der Test hat es gefangen. ## Protokoll `src/lib/auth/protokoll.ts` schreibt je Ereignis eine JSON-Zeile auf die Standardausgabe: Fehlversuche mit Zahl und Wartezeit, abgelehnte und erfolgte OIDC-Verknüpfungen, jede Vergabe von Administratorrechten. Ohne Passwörter, Token und Adressen — Konten stehen mit ihrer Id da. ## Was offen bleibt **S7.** Es gibt keine Prisma-Fassung ohne die Advisory: 7.9.1 ist die neueste und hängt weiter an `deepmerge-ts@7.1.5`. `npm audit fix --force` stufte auf Prisma 6 zurück. Dazu in `docs/sicherheit.md` unter „Offen“: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei einer Instanz — dem Regelfall — ist das verschmerzbar; die Wartezeit je Konto steht in der Datenbank und übersteht den Neustart. Mehrere Instanzen bräuchten einen gemeinsamen Zähler. ## Neue Einstellung `TRUST_PROXY` (Vorgabe `false`) in `.env.example`, `docker-compose.yml` und README. ## Geprüft - Neue Reihe zum neuen Verhalten: **45 von 45** — darunter live nachgestellt, dass fünf Fehlversuche in eine Wartezeit von einer Sekunde münden und der Besitzer danach hineinkommt. - Reihe zu den Befunden: **35 von 35**, mit den Wahrheitstabellen für `email_verified` und `AUTH_ENABLED`. - Am laufenden Server: mit leerem `AUTH_ENABLED` antwortet `/` mit 307 auf `/anmelden`, `/api/codes` mit 401. - **19 bestehende Reihen gegen den fertig gebauten Container**, alle grün — e2e 79, browser 39, browser-types 25, filter-art 28, dubletten 24, logo 44, domain-check 38, hilfe-domains 24, typen-gruppen 38, landing 31, landing-social-video 31, codes-view 33, darkmode 38, kurzlinks 57, statisch 38, uploads 48, branding 42, version 20; dazu auth 63, codes-groups 9, slug-rechte 29 und sicherheit 21 gegen eine frische Instanz mit Anmeldung. - Typprüfung, Linter und Changelog-Prüfung grün. Closes #20
florian.wilde added 1 commit 2026-08-23 23:02:54 +00:00
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
florian.wilde added 1 commit 2026-08-24 00:00:00 +00:00
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
florian.wilde merged commit 6743dc007d into main 2026-08-24 07:41:08 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: florian.wilde/qrcode-generator#21