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
+90 -1
View File
@@ -42,7 +42,7 @@ import urllib.error
import urllib.parse
import urllib.request
from harness import test, erwarte, ueberspringen, Fehlschlag
from harness import (test, erwarte, erwarte_status, ueberspringen, Fehlschlag)
# ---------------------------------------------------------------------------
@@ -171,6 +171,16 @@ class Sitzung:
except ValueError:
return e.code, {'roh': roh[:400]}
def kopfzeilen(self, pfad):
"""Antwortkopf einer Anfrage, Schluessel kleingeschrieben."""
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
try:
with self.opener.open(req, timeout=45) as r:
roh = r.headers
except urllib.error.HTTPError as e:
roh = e.headers
return {k.lower(): v for k, v in roh.items()}
def anmelden(self, benutzer, passwort):
self.hole('/login.php')
status, _ = self.formular('/login.php', {
@@ -386,3 +396,82 @@ def t_aufraeumen(ctx):
# stehen, verlangte das Testkonto beim naechsten Lauf einen zweiten
# Faktor, den niemand vorzeigen kann.
_schluessel_loeschen(ctx)
# ---------------------------------------------------------------------------
# Haertung der Anmeldewege
#
# Ergebnis einer Sicherheitsbegutachtung. Der rote Faden aller Befunde: Die
# WebAuthn-Zeremonie war in Ordnung, der zweite Faktor trotzdem umgehbar -
# ueber Wege DANEBEN. Was hier geprueft wird, sind genau diese Wege.
# ---------------------------------------------------------------------------
@test('haertung', 'Die Anmeldung verraet nicht, ob es ein Konto gibt')
def t_keine_enumeration(ctx):
"""
Gesperrt und gezaehlt wird nur, was existiert. Unterscheidet die Seite
sichtbar zwischen "Konto gesperrt" und "ungueltige Daten", genuegen
fuenf Versuche pro Name, um seine Existenz zu bestaetigen - und die
sorgfaeltige Laufzeitangleichung ueber dummyHash() waere umsonst.
Ein Fehlversuch gegen das echte Konto ist dabei unvermeidlich; die
Kontosperre greift erst beim fuenften.
"""
s = Sitzung(ctx.cfg)
s.hole('/login.php')
_, unbekannt = s.formular('/login.php', {
'username': 'gibt-es-garantiert-nicht-4711', 'password': 'beliebig'})
s2 = Sitzung(ctx.cfg)
s2.hole('/login.php')
_, vorhanden = s2.formular('/login.php', {
'username': (ctx.cfg.get('admin') or {}).get('user') or 'admin',
'password': 'garantiert-falsch'})
def meldung(seite):
treffer = re.search(r'<div class="alert-danger">(.*?)</div>', seite, re.S)
return re.sub(r'\s+', ' ', treffer.group(1)).strip() if treffer else ''
a, b = meldung(unbekannt), meldung(vorhanden)
erwarte(a != '', 'Kein Fehlertext fuer den unbekannten Namen gefunden.')
erwarte(a == b,
'Die Meldungen unterscheiden sich und verraten damit die Existenz '
'des Kontos:\n unbekannt: %r\n vorhanden: %r' % (a, b))
@test('haertung', 'Die Sicherheits-Header sind gesetzt')
def t_header(ctx):
"""
HSTS und CSP. Zur CSP gehoert die Einschraenkung, dass sie wegen der
Inline-Skripte in index.php 'unsafe-inline' enthaelt und deshalb NICHT
gegen XSS traegt - geprueft wird hier, dass die Richtlinien ueberhaupt
ankommen, nicht dass sie alles loesen.
"""
s = Sitzung(ctx.cfg)
kopf = s.kopfzeilen('/login.php')
hsts = kopf.get('strict-transport-security', '')
erwarte('max-age=' in hsts, 'Kein Strict-Transport-Security: %r' % hsts)
csp = kopf.get('content-security-policy', '')
for pflicht in ("frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"):
erwarte(pflicht in csp, 'In der CSP fehlt %r: %r' % (pflicht, csp))
@test('haertung', 'Die Gesundheitsauskunft nennt den WebAuthn-Anker')
def t_anker(ctx):
"""
rpId und expectedOrigin stammen nicht mehr aus dem Host-Header. Die
Folge ist, dass eine Anlage ohne app.url den Hardwarekey abweist - und
genau das soll man sehen koennen, BEVOR jemand sich anzumelden versucht.
"""
r = ctx.client.get('/api/health', token=None)
erwarte_status(r, 200)
anker = r.feld('checks', 'webauthn_anchor')
erwarte(isinstance(anker, dict),
'Die Gesundheitsauskunft nennt keinen webauthn_anchor: %s' % r.kurz(300))
erwarte(anker.get('ok') is True,
'Der WebAuthn-Anker ist nicht gesetzt - app.url fehlt: %s' % anker)