fix(2.9.3): Der zweite Faktor war neben der Zeremonie umgehbar

Ergebnis einer beauftragten Sicherheitsbegutachtung. Die WebAuthn-
Zeremonie selbst ist nicht angefasst worden - sie war in Ordnung. Alle
Befunde lagen DANEBEN:

  Ein zweiter Faktor ist nur so stark wie der schwaechste Weg zum Konto,
  und Sitzungen, die aus ihm entstehen, erben seinen Schutz nicht.

KRITISCH
/api/setup/v1/login prueste auf totp_secret - eine Spalte, die kein
Anmeldeweg auswertet - und nicht auf dc_user_credentials. Ein
Administratorpasswort genuegte dort fuer ein setup:install-Token ohne
Hardwarekey. Neu: Konten mit zweitem Faktor abgewiesen, und die
Rollentrennung gilt symmetrisch. Der Docblock behauptete sie seit jeher;
erzwungen hat sie nichts.

HOCH
secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds()
fingen jedes Throwable und fielen auf "kein Faktor" bzw. "nicht
gesperrt" zurueck. Gefangen wurden damit auch Verbindungsabbrueche und
Lock-Timeouts - jeder davon schaltete still fuer alle Konten auf
Passwort-allein. Toleriert werden jetzt nur noch 42S02 und 42S22.

HOCH
Fuenf Aktionen aenderten die Anmeldemittel ohne erneute Bestaetigung.
Eine uebernommene Sitzung konnte sich damit dauerhaft einnisten. Neu:
Authentifizierung aus den letzten zehn Minuten noetig, geprueft an
derselben Stelle, an der auch der Leerlauf sperrt.

WEITER
- Leerlauf 30 statt 120 Minuten, und GESPERRT statt beendet: AAL2 laesst
  zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten den
  Hardwarekey vorzeigen muss, laesst ihn stecken.
- Migration 022: session_epoch. Ein Passwortwechsel beendet die uebrigen
  Sitzungen des Kontos, die eigene bleibt.
- Kontosperre wirkt auf der zweiten Stufe; Zaehler gegen unbegrenztes
  Raten im Zwischenzustand.
- Drosselung auf login.php (10/60s) und /api/auth/v1/ (30/60s).
- Anmeldefehler ununterscheidbar; die IP-Sperre darf benannt bleiben,
  sie sagt nichts ueber ein Konto.
- HSTS, TLS-Zwang VOR allen Routen, CSP. Die CSP traegt wegen 93
  Inline-Handlern nicht gegen XSS - das steht so im Kommentar.
- session.use_strict_mode, CSRF-Token bei der Anmeldung verworfen,
  Secure-Flag aus app.url statt aus $_SERVER['HTTPS'].
- rpId/expectedOrigin nicht mehr aus dem Host-Header. /api/health meldet
  unter checks.webauthn_anchor, ob der Anker steht.
- Notfalltoken laeuft nach 180 Tagen ab, geprueft nach dem Hashvergleich.
- config.example.php ohne echtes Passwort, verify_ssl auf true.
- Serverdaten.txt.bak mitgesperrt, dc_login_attempts raeumt auf.

BELEGT
188 von 188 Testfaellen gruen, darunter drei neue in der Gruppe
"haertung". Die Blockade einer veralteten Sitzung liess sich nicht in
der Suite pruefen - sie verlangt, das Zehn-Minuten-Fenster wirklich
abzuwarten. Dafuer gibt es ein Werkzeug ausserhalb; es belegt, dass der
Formularweg auf den Sperrbildschirm fuehrt, die Regel dabei unveraendert
bleibt, der JSON-Weg reauth_required meldet und das Passwort allein
wieder oeffnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-09-21 14:58:31 +02:00
co-authored by Claude Opus 5
parent 0c20698fd5
commit 91d8bf353e
20 changed files with 1571 additions and 93 deletions
+97
View File
@@ -785,3 +785,100 @@ aushebelbar zu machen.
Schlüssel am eigenen Konto hängt
- [ ] Nach `strict`: Anmeldung mit dem Yubikey funktioniert weiter
- [ ] `python tests/e2e/run.py --gruppe hardware`: alles grün
---
## 19. Umstellung auf 2.9.3 — Ergebnis der Sicherheitsbegutachtung
### 19.1 Was gefunden wurde
Die WebAuthn-Zeremonie selbst war in Ordnung. Der zweite Faktor ließ sich
trotzdem umgehen — **nicht in der Zeremonie, sondern daneben**:
| Weg | Was möglich war |
|---|---|
| `/api/setup/v1/login` | Passwort allein genügte für ein `setup:install`-Token — der Endpunkt prüfte `totp_secret`, eine Spalte, die kein Anmeldeweg auswertet |
| Datenbankfehler | `secondFactorRequired()` fing jedes `Throwable` und antwortete „kein zweiter Faktor" — ein Lock-Timeout schaltete die Anlage still auf Passwort-allein |
| Übernommene Sitzung | Konnte einen eigenen Hardwarekey registrieren, Notfalltokens ausstellen und die Regel abschalten — ohne eine einzige erneute Bestätigung |
Der rote Faden: Ein zweiter Faktor ist nur so stark wie der **schwächste Weg
zum Konto**. Zwei der drei Befunde waren Stellen, an denen ein Kommentar eine
Zusicherung behauptete, die der Code nicht einlöste.
### 19.2 Vor dem Deployment prüfen
> **`app.url` muss in der `config.php` gesetzt sein.**
`rpId()` und `expectedOrigin()` leiten sich nicht mehr aus dem Host-Header ab —
aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie
geprüft wird. Fehlt `app.url` **und** `security.webauthn_rp_id`, wird der
Hardwarekey abgewiesen.
```bash
curl -s https://dc.mhdf.de/api/health | grep -o '"webauthn_anchor":{[^}]*}'
```
`"ok":true` — alles in Ordnung. `"ok":false` — vor dem Deployment eintragen:
```php
'app' => ['url' => 'https://dc.mhdf.de'],
```
### 19.3 Migration
```bash
php cli/migrate.php # oder System → 🗄️ DB Migration
```
Migration 022 ergänzt `dc_users.session_epoch`. Bestehende Anmeldungen bleiben
gültig — eine Sicherheitsverbesserung, die im Moment ihrer Einführung alle
Angemeldeten hinauswirft, wird beim nächsten Mal abgeschaltet.
### 19.4 Was sich im Arbeitsablauf ändert
- **Nach 30 Minuten Untätigkeit wird die Sitzung gesperrt**, nicht beendet.
Der Sperrbildschirm öffnet mit dem **Passwort allein** — der Hardwarekey
wird erst bei einer neuen Anmeldung wieder verlangt.
- **Fünf Aktionen verlangen eine Bestätigung**, wenn die letzte
Authentifizierung über zehn Minuten zurückliegt: Passwort setzen,
Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die
Hardwarekey-Regel umstellen. Es erscheint derselbe Sperrbildschirm.
- **Ein Passwortwechsel beendet alle anderen Sitzungen** des Kontos. Die
eigene bleibt bestehen.
- **Die Anmeldung nennt keine Gründe mehr.** Falscher Name, falsches Passwort,
deaktiviertes und gesperrtes Konto ergeben dieselbe Meldung. Die Wartezeit
steht darin, der Grund nicht — gesperrt wird nur, was existiert, und eine
eigene Meldung dafür bestätigte die Existenz des Namens.
### 19.5 Was Sie selbst erledigen müssen
- [ ] **Rocket.Chat-Passwort rotieren.** In `config/config.example.php` stand
ein echt aussehendes Passwort — die Beispieldatei liegt im Repository,
und damit lag es dort auch. Ersetzt durch `''`; der Wert kommt jetzt aus
`DC_RC_PASS`.
- [ ] **`verify_ssl` prüfen.** Die Vorgabe der Beispieldatei stand auf
`false` und steht jetzt auf `true`. Wer den alten Wert übernommen hat,
prüfte Zertifikate nicht.
- [ ] **Installationskonto anlegen**, falls Sie die Setup-API mit einem
Administratorkonto benutzt haben. Rolle `installer`, unter System →
Benutzerkonten.
- [ ] **Notfalltoken erneuern**, wenn er älter als 180 Tage ist. Er läuft
jetzt ab; die Oberfläche zeigt die Restlaufzeit.
### 19.6 Was bewusst offen bleibt
| Punkt | Warum nicht jetzt |
|---|---|
| CSP ohne `'unsafe-inline'` | Verlangt das Herauslösen von 93 Inline-Handlern und mehreren tausend Zeilen Inline-Skript aus `index.php`. Ein eigenes Vorhaben, kein Header. **Bis dahin schützt die CSP nicht gegen XSS** — sie schützt gegen fremde Skriptquellen, Einbettung und umgebogene Formularziele. |
| Rollenmodell im WebUI | `ApiAuth` gibt einer Session jeden Scope ohne Prüfung; jeder WebUI-Benutzer ist Volladministrator. Ein echtes Berechtigungsmodell ist ein Vorhaben für sich. |
| Document Root | Der gesamte Quellbaum inklusive `config/` hängt am Web und wird von `mod_rewrite` plus Verzeichnis-`.htaccess` gehalten. Richtig wäre `public/` als Root — das ist eine Änderung an der Hosting-Konfiguration, nicht am Code. |
### 19.7 Prüfen, ob alles läuft
- [ ] `/api/health` → `checks.webauthn_anchor.ok` ist `true`
- [ ] Anmeldung mit Passwort und Hardwarekey funktioniert
- [ ] `curl -I https://dc.mhdf.de/login.php` zeigt
`Strict-Transport-Security` und `Content-Security-Policy`
- [ ] `curl -I http://dc.mhdf.de/login.php` antwortet mit `301` auf `https://`
- [ ] Setup-API mit einem Administratorkonto → `403 installer_account_required`
- [ ] `python tests/e2e/run.py` gegen die Testinstanz: alles grün