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
+93 -1
View File
@@ -1,8 +1,100 @@
{
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
"schema": 1,
"current": "2.9.2",
"current": "2.9.3",
"versions": [
{
"version": "2.9.3",
"date": "2026-09-21",
"summary": "Ergebnis einer Sicherheitsbegutachtung. Die WebAuthn-Zeremonie selbst war in Ordnung - der zweite Faktor liess sich aber DANEBEN umgehen: ueber einen Anmeldeweg, der ihn nicht kannte, ueber eine Pruefung, die bei Datenbankfehlern stillschweigend auf Passwort-allein zurueckfiel, und ueber eine uebernommene Sitzung, die die Anmeldemittel ohne erneute Bestaetigung aendern durfte.",
"actionRequired": true,
"changes": [
{
"area": "security",
"breaking": true,
"title": "Die Setup-API kannte den zweiten Faktor nicht",
"text": "POST /api/setup/v1/login pruefte bisher auf totp_secret - eine Spalte, die KEIN Anmeldeweg auswertet. Auf dc_user_credentials, also den tatsaechlich umgesetzten zweiten Faktor, wurde nicht geprueft. Wer ein Administratorpasswort hatte, bekam dort ohne Hardwarekey ein gueltiges setup:install-Token und konnte damit dauerhafte Tokens fuer jedes Projekt ausstellen und Monitore ueberschreiben. Neu werden Konten mit hinterlegtem Hardwarekey abgewiesen, und die Rollentrennung gilt symmetrisch: Installer kommen nicht ins WebUI (das galt schon), Administratoren nicht mehr in die Setup-API. Der Docblock behauptete diese Trennung seit jeher - erzwungen hat sie nichts.",
"action": "Wer die Setup-API mit einem Administratorkonto benutzt hat, legt vorher ein Konto der Rolle \"installer\" an (System -> Benutzerkonten)."
},
{
"area": "security",
"breaking": false,
"title": "Authentifizierung scheitert jetzt geschlossen",
"text": "secondFactorRequired(), accountLockoutSeconds() und lockoutSeconds() fingen jedes Throwable und antworteten mit \"kein zweiter Faktor\" bzw. \"nicht gesperrt\". Begruendet war das mit einer fehlenden Tabelle vor Migration 019 - gefangen wurden aber auch Verbindungsabbrueche, Lock-Timeouts, \"too many connections\" und entzogene Tabellenrechte. Jeder dieser Faelle schaltete im Stillen fuer ALLE Konten auf Passwort-allein zurueck und hob gleichzeitig den Brute-Force-Schutz auf. Toleriert werden jetzt nur noch die SQL-Zustaende 42S02 und 42S22 (Tabelle bzw. Spalte unbekannt); alles andere bricht ab. Lieber keine Anmeldung als eine ohne zweiten Faktor.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Aenderungen an den Anmeldemitteln verlangen das Passwort",
"text": "Passwort setzen, Notfalltoken ausstellen, Hardwarekey aufnehmen oder entfernen, die Hardwarekey-Regel umstellen: Fuer all das genuegte eine lebende Sitzung samt CSRF-Token. Eine einzige uebernommene Sitzung reichte damit, um sich dauerhaft einzunisten - einen eigenen Schluessel zu registrieren, einen Notfalltoken fuer jedes Konto auszustellen und den zweiten Faktor anlagenweit abzuschalten. Der Hardwarekey haette gegen genau den Fall nicht geholfen, gegen den er gedacht ist: Er schuetzt die Anmeldung, nicht die Sitzung, die daraus entsteht. Diese fuenf Aktionen verlangen jetzt eine Authentifizierung aus den letzten zehn Minuten.",
"action": null
},
{
"area": "security",
"breaking": true,
"title": "Leerlauf 30 Minuten - gesperrt statt beendet",
"text": "Bisher zwei Stunden, danach wurde die Sitzung vernichtet. NIST SP 800-63B verlangt auf Stufe AAL2 eine erneute Authentifizierung nach 30 Minuten Untaetigkeit; der ganze Aufwand mit dem Hardwarekey hing damit an einer Zahl, die niemand fuer eine Sicherheitsentscheidung hielt. Statt zu vernichten wird jetzt gesperrt: Der Sperrbildschirm oeffnet mit dem PASSWORT allein, derselbe Standard laesst zur Reaktivierung einen Faktor genuegen. Wer alle 30 Minuten Passwort UND Hardwarekey vorzeigen muss, legt den Schluessel irgendwann steckend liegen - eine Regel, die zur Umgehung erzieht, ist schlechter als eine mildere, die eingehalten wird. Das absolute Maximum von 12 Stunden beendet die Sitzung weiterhin wirklich.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Ein Passwortwechsel beendet die uebrigen Sitzungen",
"text": "Bisher beendete er keine einzige: Wer das Passwort aendert, weil er einen fremden Zugriff vermutet, liess damit genau den drin, dessentwegen er es aendert. PHP legt Sitzungen als Dateien ab, es gibt keinen Index \"alle Sitzungen des Kontos X\". Migration 022 loest das mit einer Spalte session_epoch: Jede Sitzung merkt sich beim Anmelden den Stand, ein Passwortwechsel erhoeht ihn, alle uebrigen passen nicht mehr. Die eigene Sitzung uebernimmt den neuen Stand - wer sein Passwort aendert, soll sich nicht selbst hinauswerfen.",
"action": "Migration 022 ausfuehren."
},
{
"area": "security",
"breaking": false,
"title": "Sperren greifen jetzt auch auf der zweiten Stufe",
"text": "pendingUserId() pruefte nur das 5-Minuten-Fenster, completeSecondFactor() nur disabled. Ein waehrend des Ratens gesperrtes Konto konnte die Anmeldung trotzdem abschliessen, und innerhalb des offenen Zwischenzustands durfte unbegrenzt geraten werden - obwohl der Kommentar an recordSecondFactorFailure genau das zu verhindern versprach. Neu: Kontosperre wird beim Abschluss geprueft, und nach fuenf Fehlversuchen faellt der Zwischenzustand. Praktisch war das folgenlos (der Notfalltoken hat rund 100 Bit und ist bcrypt-gehasht), die Kontrolle fehlte aber.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Drosselung und keine Namens-Enumeration am Login",
"text": "Der RateLimiter war im Haus und auf acht Endpunkten im Einsatz - ausgerechnet login.php und /api/auth/v1/ waren ohne. Neu ein Eimer webui_login mit 10 Versuchen pro Minute. Ausserdem meldete die Anmeldung \"Dieses Benutzerkonto ist gesperrt\" gegen \"Ungueltige Anmeldedaten\" - gesperrt wird aber nur, was existiert, also verrieten fuenf Versuche pro Name, ob es ihn gibt. Die sorgfaeltige Laufzeitangleichung ueber dummyHash() war damit wieder aufgehoben. Jetzt eine Meldung fuer alle Faelle; die Wartezeit darf darin stehen, der Grund nicht. Die IP-Sperre wird weiterhin benannt - sie sagt etwas ueber das Netzwerk aus, nicht ueber ein Konto.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "HSTS, TLS-Zwang und eine ehrliche CSP",
"text": "Neu: Strict-Transport-Security, eine Umleitung von http auf https VOR allen Routen (dahinter waere sie fuer login.php und die gesamte API nie erreicht worden, weil alle Routen [L] tragen), und eine Content-Security-Policy. Die CSP enthaelt 'unsafe-inline' fuer script-src, und das ist keine Nachlaessigkeit, sondern eine Tatsache ueber den Bestand: index.php hat 93 Inline-Handler und mehrere tausend Zeilen Inline-Skript. Sie schuetzt deshalb NICHT gegen XSS, sondern gegen Skript von fremden Adressen, Einbettung, umgebogenes <base>, fremde Formularziele und Plugins. Das hinzuschreiben ist wichtiger, als es zu verschweigen - ein CSP-Header laedt sonst dazu ein, XSS fuer erledigt zu halten.",
"action": null
},
{
"area": "security",
"breaking": false,
"title": "Sitzungshaertung und der WebAuthn-Anker",
"text": "session.use_strict_mode ist gesetzt (PHP-Vorgabe ist aus - der Server uebernahm jede vom Client erfundene Sitzungskennung). Das CSRF-Token wird bei der Anmeldung verworfen, statt die Regeneration zu ueberleben. Das Secure-Flag des Cookies haengt nicht mehr an $_SERVER['HTTPS'], das hinter einem TLS-terminierenden Proxy leer ist, sondern an app.url. Und rpId()/expectedOrigin() leiten sich nicht mehr aus dem Host-Header ab: Aus diesem Wert entstehen die beiden Anker, gegen die die ganze Zeremonie geprueft wird - wer den Header setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird.",
"action": "app.url MUSS in der config.php gesetzt sein, sonst wird der Hardwarekey abgewiesen. /api/health meldet das unter checks.webauthn_anchor."
},
{
"area": "security",
"breaking": false,
"title": "Notfalltoken laeuft nach 180 Tagen ab",
"text": "recovery_created_at wurde seit 2.9.0 geschrieben und nie ausgewertet. Ein vor zwei Jahren auf einen Zettel geschriebener Token war weiterhin ein vollwertiger Ersatz fuer den Hardwarekey - ein zweiter Faktor, der nicht altert, waehrend der Zettel es tut. Geprueft wird NACH dem Hashvergleich, damit die Antwortzeit nicht verraet, ob ein Token existiert. Die Oberflaeche zeigt jetzt die Restlaufzeit und warnt 30 Tage vorher.",
"action": "Notfalltoken, die aelter als 180 Tage sind, neu erzeugen."
},
{
"area": "security",
"breaking": false,
"title": "Kleinkram mit Folgen",
"text": "config/config.example.php enthielt ein echt aussehendes Rocket.Chat-Passwort und verify_ssl => false als Vorgabe - eine Beispieldatei liegt im Repository, und damit lag das Passwort dort auch. Beides bereinigt. Die .htaccess sperrte Serverdaten.txt, aber nicht Serverdaten.txt.bak; deploy.py schliesst beide aus, die zweite Verteidigungslinie muss aber zur ersten passen. dc_login_attempts wurde nie aufgeraeumt - eine Tabelle, die nur waechst und bei JEDER Anmeldung gelesen wird, wird irgendwann zu dem Problem, das die Anmeldung langsam macht; jetzt raeumt ein Hundertstel der Anmeldungen beilaeufig auf.",
"action": "Das Rocket.Chat-Passwort rotieren - es stand im Repository."
},
{
"area": "tests",
"breaking": false,
"title": "Die Setup-Testfaelle pruefen jetzt die Trennung",
"text": "Sie meldeten sich bisher mit dem Administratorkonto an - sie bildeten also gerade den Weg ab, den es nicht geben sollte. testctl legt beim Zuruecksetzen ein Installationskonto an, die Faelle benutzen es, und ein neuer Fall prueft ausdruecklich, dass ein Administratorkonto dort abgewiesen wird. 185 von 185 gruen.",
"action": null
}
]
},
{
"version": "2.9.2",
"date": "2026-09-21",