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
+89 -30
View File
@@ -18,10 +18,17 @@
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
*
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt,
* verteilt den Zugang zur gesamten Verwaltung.
* ROLLENTRENNUNG: Seit 2.9.3 duerfen sich hier NUR Konten der Rolle
* "installer" anmelden - die sich ihrerseits nicht am WebUI anmelden koennen.
* Bis dahin stand an dieser Stelle "gedacht ist der Weg fuer Konten der Rolle
* installer", und erzwungen war nichts; der Satz beschrieb eine Absicht, die
* der Code nicht teilte. Wer auf jedem Zielsystem sein Admin-Passwort
* eingibt, verteilt den Zugang zur gesamten Verwaltung.
*
* ZWEITER FAKTOR: Konten mit hinterlegtem Hardwarekey werden hier ebenfalls
* abgewiesen. Dieser Weg kennt die WebAuthn-Zeremonie nicht, und ein
* Anmeldeweg, der den zweiten Faktor einfach uebergeht, macht ihn ueberall
* wertlos.
*/
declare(strict_types=1);
@@ -33,6 +40,7 @@ use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\Setup\SetupCatalog;
@@ -77,44 +85,95 @@ switch (resolveSetupAction()) {
), null, ['retry_after_seconds' => $ipLockout]);
}
// Die Kontosperre wird geprueft, aber NICHT als solche gemeldet.
// Eine eigene Antwort "account_locked" verriete, dass es den Namen
// gibt - gesperrt wird nur, was existiert. Die sorgfaeltige
// Laufzeitangleichung ueber dummyHash() in verifyCredentials() waere
// damit wieder aufgehoben: Fuenf Versuche pro Name genuegten, um ihn
// zu bestaetigen.
$accLockout = Auth::accountLockoutSeconds($db, $username);
if ($accLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
), null, ['retry_after_seconds' => $accLockout]);
}
$user = Auth::verifyCredentials($db, $username, $password);
$user = $accLockout > 0 ? null : Auth::verifyCredentials($db, $username, $password);
if ($user === null) {
$postAccLockout = Auth::accountLockoutSeconds($db, $username);
if ($postAccLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
(int)ceil($postAccLockout / 60)
), null, ['retry_after_seconds' => $postAccLockout]);
}
// Eine Antwort fuer alle Faelle: falscher Name, falsches Passwort,
// deaktiviertes Konto, gesperrtes Konto. Die Wartezeit darf mit -
// sie ist ein Hinweis fuer den Berechtigten und sagt einem
// Ratenden nichts, was er nicht ohnehin ausprobieren wuerde.
$warten = max($accLockout, Auth::accountLockoutSeconds($db, $username));
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
// Passwort und deaktiviertes Konto.
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
Http::fail(
401,
'invalid_credentials',
$warten > 0
? sprintf(
'Anmeldung nicht moeglich. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($warten / 60)
)
: 'Anmeldung nicht moeglich.',
null,
$warten > 0 ? ['retry_after_seconds' => $warten] : []
);
}
// TOTP ist in dc_users vorgesehen, wird aber noch von keinem
// Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt.
if (!empty($user['totp_secret'])) {
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [
// ------------------------------------------------------------------
// Zweiter Faktor
// ------------------------------------------------------------------
// Bis 2.9.2 stand hier nur die Pruefung auf totp_secret - eine Spalte,
// die von KEINEM Anmeldeweg ausgewertet wird. Der tatsaechlich
// umgesetzte zweite Faktor liegt in dc_user_credentials, und der
// wurde hier nicht geprueft.
//
// Die Folge war eine offene Tuer neben der verschlossenen: Wer ein
// Administratorpasswort hatte, bekam hier ohne Hardwarekey ein
// gueltiges setup:install-Token und konnte damit dauerhafte Tokens
// fuer jedes Projekt ausstellen. Die Aussage "dieses Konto ist durch
// einen zweiten Faktor geschuetzt" stimmte damit nicht mehr - und
// eine Aussage ueber Sicherheit, die nur fuer einen von zwei Wegen
// gilt, ist keine.
if (!empty($user['totp_secret']) || Auth::secondFactorRequired($db, (int)$user['id'])) {
Logger::warning('Setup-Anmeldung eines Kontos mit zweitem Faktor abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
]);
SecurityLogger::log('setup_login_second_factor', Http::clientIp(), [
'username' => $username,
]);
Http::fail(
501,
'totp_not_supported',
403,
'second_factor_required',
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
. 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.'
. 'unterstuetzt ihn nicht - bitte ein eigenes Installationskonto verwenden.'
);
}
// ------------------------------------------------------------------
// Rollentrennung, und zwar symmetrisch
// ------------------------------------------------------------------
// Der Docblock dieses Endpunkts sagte seit jeher, gedacht sei er fuer
// Konten der Rolle "installer" - erzwungen hat das nichts: "Anmelden
// darf sich hier jedes Konto."
//
// startLogin() haelt Installer umgekehrt schon immer vom WebUI fern.
// Die Trennung galt also nur in eine Richtung, und die
// unbeaufsichtigte Richtung war die gefaehrlichere: Ein Zielsystem,
// auf dem Installationsdaten eingegeben werden, ist der Ort, an dem
// sie am ehesten abhandenkommen.
if (Auth::roleOf($user) !== 'installer') {
Logger::warning('Setup-Anmeldung eines Nicht-Installer-Kontos abgelehnt', [
'username' => $username,
'role' => Auth::roleOf($user),
'ip' => Http::clientIp(),
]);
SecurityLogger::log('setup_login_wrong_role', Http::clientIp(), [
'username' => $username,
'role' => Auth::roleOf($user),
]);
Http::fail(
403,
'installer_account_required',
'Die Setup-Anmeldung steht nur Konten der Rolle "installer" offen. '
. 'Ein solches Konto legen Sie im WebUI unter System -> Benutzerkonten an.'
);
}