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 -4
View File
@@ -230,6 +230,49 @@ function dc_flash(string $message, string $type = 'success'): void
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
}
/**
* Verlangt eine frische Authentifizierung fuer Aenderungen an den
* Anmeldemitteln selbst.
*
* WARUM DAS NOETIG IST
*
* Bis 2.9.2 genuegte fuer jede dieser Aktionen eine lebende Sitzung samt
* CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich
* dauerhaft einzunisten: einen eigenen Hardwarekey registrieren, einen
* Notfalltoken fuer jedes Konto ausstellen, den zweiten Faktor anlagenweit
* abschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen,
* gegen den er gedacht ist - er schuetzt die Anmeldung, nicht die Sitzung,
* die daraus entsteht.
*
* Der Aufrufer bricht ab; diese Funktion kehrt bei fehlender frischer
* Authentifizierung nicht zurueck.
*/
function dc_require_reauth(string $rueckkehr = 'sub-system-users'): void
{
if (Auth::reauthFresh()) {
return;
}
// Der Hinweis auf die Wiederholung ist kein Zierrat: Die Formulardaten
// gehen bei der Umleitung verloren, und ohne diesen Satz sucht man nach
// dem Entsperren vergeblich nach der Wirkung.
dc_flash(
'Diese Aenderung betrifft die Anmeldung selbst. Bitte bestaetigen Sie sie mit '
. 'Ihrem Passwort und wiederholen Sie die Aktion danach.',
'danger'
);
// Der Sperrbildschirm erledigt genau das - er prueft ein Passwort und
// setzt den Zeitstempel. Statt eines zweiten Formulars derselben Art
// wird die Sitzung dafuer kurz gesperrt.
Auth::lockForReauth();
if (!headers_sent()) {
header('Location: /login.php?step=unlock&weiter=' . rawurlencode('/index.php#' . $rueckkehr));
}
exit;
}
/**
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
*
@@ -1522,6 +1565,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'set_user_password': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$newPass = (string)($_POST['password'] ?? '');
@@ -1533,13 +1578,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
$geaendert = $upd->rowCount() > 0;
if ($geaendert) {
// Alle uebrigen Sitzungen dieses Kontos beenden. Ein
// Passwort zu aendern, ohne die Sitzungen zu beenden, die
// mit dem alten eroeffnet wurden, laesst genau den drin,
// dessentwegen man es aendert. Die eigene Sitzung
// uebernimmt den neuen Stand und bleibt bestehen.
Auth::bumpSessionEpoch($pdo, $userId);
SecurityLogger::log('password_changed', Http::clientIp(), [
'username' => $actor,
'user_id' => $userId,
]);
}
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_flash(
$geaendert
? 'Passwort geaendert. Andere Sitzungen dieses Kontos sind damit beendet.'
: 'Konto nicht gefunden.',
$geaendert ? 'success' : 'danger'
);
dc_redirect('#sub-system-users');
}
@@ -1614,6 +1679,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'issue_recovery_token': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1');
@@ -1644,6 +1711,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'save_webauthn_policy': {
dc_require_reauth();
$policy = new HardwarePolicy($pdo);
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
$before = $policy->level();
@@ -1688,6 +1757,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
case 'delete_credential': {
dc_require_reauth();
$userId = (int)($_POST['user_id'] ?? 0);
$credentialId = (int)($_POST['credential_id'] ?? 0);
@@ -6847,8 +6918,20 @@ Antwort:
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Notfalltoken für Ihr eigenes Konto:
<?php if ($myRecovery['present']): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?>.
<?php if (!empty($myRecovery['expired'])): ?>
<span style="color:var(--danger);">abgelaufen</span> —
hinterlegt am <?= e((string)$myRecovery['created_at']) ?>,
also vor <?= (int)$myRecovery['age_days'] ?> Tagen. Er öffnet nichts mehr;
bitte einen neuen erzeugen.
<?php elseif ($myRecovery['present']): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?><?php
if ($myRecovery['age_days'] !== null) {
$rest = (int)(CredentialRepo::RECOVERY_MAX_AGE / 86400) - (int)$myRecovery['age_days'];
echo $rest <= 30
? ', <span style="color:var(--warning);">läuft in ' . $rest . ' Tag(en) ab</span>'
: ' (' . $rest . ' Tage gültig)';
}
?>.
<?php elseif (!empty($myRecovery['used_at'])): ?>
<span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span>
<?php else: ?>
@@ -9237,6 +9320,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
// Eine fehlende Passwortbestätigung ist kein Fehler, sondern ein
// Zwischenschritt. Sie als roten Text stehen zu lassen, hieße dem
// Benutzer eine Sackgasse zu zeigen, aus der er den Ausweg selbst
// suchen müsste.
const huelle = data && data.error;
if (huelle && huelle.code === 'reauth_required' && huelle.reauth_url) {
setStatus('Bitte kurz mit dem Passwort bestätigen …');
window.location.href = huelle.reauth_url;
return new Promise(() => {});
}
throw new Error(fehlertext(data));
}
return data;