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
+30
View File
@@ -200,6 +200,33 @@ switch ($action) {
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
}
// ------------------------------------------------------------------
// Installationskonto
// ------------------------------------------------------------------
// Seit 2.9.3 nimmt /api/setup/v1/login nur noch Konten der Rolle
// "installer" an. Die Setup-Testfaelle benutzten bis dahin das
// Administratorkonto - und pruefen damit gerade den Weg, der jetzt
// verschlossen ist. Ein eigenes Konto hier anzulegen ist nicht bloss
// Testkosmetik: Es bildet ab, wie der Weg in der Produktion gedacht
// ist, und die Testfaelle liefen bisher auf einer Abkuerzung.
$installerUser = $adminUser . '-installer';
$installerPass = bin2hex(random_bytes(12));
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $installerUser]);
$db->prepare('
INSERT INTO dc_users (username, password_hash, created_at)
VALUES (:u, :h, UTC_TIMESTAMP())
')->execute([':u' => $installerUser, ':h' => password_hash($installerPass, PASSWORD_DEFAULT)]);
$installerOk = false;
try {
$db->prepare('UPDATE dc_users SET role = "installer" WHERE username = :u')
->execute([':u' => $installerUser]);
$installerOk = true;
} catch (Throwable $e) {
// Ohne Rollenspalte (vor Migration 012) gibt es keine Installer.
}
$manager = new TokenManager($db);
$master = $manager->createMasterToken(
'Testlauf (automatisch)',
@@ -215,6 +242,9 @@ switch ($action) {
'dropped' => $dropped,
'migration' => $result,
'admin' => ['username' => $adminUser, 'password' => $adminPass],
'installer' => $installerOk
? ['username' => $installerUser, 'password' => $installerPass]
: null,
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
'state' => $status(),
]);