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:
co-authored by
Claude Opus 5
parent
0c20698fd5
commit
91d8bf353e
@@ -466,6 +466,9 @@ def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=
|
||||
return 2
|
||||
ctx['master_token'] = r.feld('master', 'raw_token')
|
||||
ctx['admin'] = r.feld('admin')
|
||||
# Das Installationskonto. Seit 2.9.3 nimmt /api/setup/v1/login nur
|
||||
# noch die Rolle "installer" an; die Setup-Faelle brauchen es.
|
||||
ctx['installer'] = r.feld('installer')
|
||||
client.token = ctx['master_token']
|
||||
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
|
||||
len(r.feld('migration', 'applied', default=[])),
|
||||
|
||||
@@ -30,6 +30,7 @@ Gruppen:
|
||||
setup Erstinstallation
|
||||
supervisor Befunde
|
||||
hardware Welche Authenticatoren zugelassen sind
|
||||
haertung Haertung der Anmeldewege
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
@@ -418,11 +418,25 @@ def _(ctx):
|
||||
# Erstinstallation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _installer(ctx):
|
||||
"""Zugangsdaten des Installationskontos, oder ueberspringen."""
|
||||
konto = ctx.get('installer')
|
||||
if not konto or not konto.get('username'):
|
||||
ueberspringen('Kein Installationskonto - Lauf ohne --reset oder vor Migration 012.')
|
||||
return konto
|
||||
|
||||
|
||||
@test('setup', 'Anmeldung liefert ein kurzlebiges Token')
|
||||
def _(ctx):
|
||||
"""
|
||||
Bewusst mit dem INSTALLATIONSKONTO, nicht mit dem Administrator. Bis
|
||||
2.9.3 nahm dieser Endpunkt jedes Konto an, und dieser Testfall bildete
|
||||
das ab - er pruefte damit einen Weg, den es nicht geben sollte.
|
||||
"""
|
||||
konto = _installer(ctx)
|
||||
r = ctx.client.post('/api/setup/v1/login', token=None, body={
|
||||
'username': ctx.cfg['admin']['user'],
|
||||
'password': ctx.cfg['admin']['pass'],
|
||||
'username': konto['username'],
|
||||
'password': konto['password'],
|
||||
'hostname': MASCHINE,
|
||||
})
|
||||
erwarte_erfolg(r)
|
||||
@@ -445,10 +459,30 @@ def _(ctx):
|
||||
"Unerwartete Rechte am Setup-Token: %s" % scopes)
|
||||
|
||||
|
||||
@test('setup', 'Ein Administratorkonto kommt hier nicht herein')
|
||||
def _(ctx):
|
||||
"""
|
||||
Die Rollentrennung gilt seit 2.9.3 in beide Richtungen: Installer nicht
|
||||
ins WebUI, Administratoren nicht in die Setup-API. Wer auf jedem
|
||||
Zielsystem sein Administratorpasswort eingibt, verteilt den Zugang zur
|
||||
gesamten Verwaltung - und bis 2.9.3 bekam er dafuer auch noch ein Token,
|
||||
ohne den hinterlegten Hardwarekey vorzeigen zu muessen.
|
||||
"""
|
||||
r = ctx.client.post('/api/setup/v1/login', token=None, body={
|
||||
'username': ctx.cfg['admin']['user'],
|
||||
'password': ctx.cfg['admin']['pass'],
|
||||
'hostname': MASCHINE,
|
||||
})
|
||||
erwarte_status(r, 403)
|
||||
erwarte(r.fehlercode in ('installer_account_required', 'second_factor_required'),
|
||||
'Erwartet wurde eine Ablehnung wegen Rolle oder zweitem Faktor, kam: %s' % r.kurz(300))
|
||||
|
||||
|
||||
@test('setup', 'Anmeldung mit falschem Passwort abgewiesen')
|
||||
def _(ctx):
|
||||
konto = _installer(ctx)
|
||||
r = ctx.client.post('/api/setup/v1/login', token=None, body={
|
||||
'username': ctx.cfg['admin']['user'], 'password': 'garantiert-falsch',
|
||||
'username': konto['username'], 'password': 'garantiert-falsch',
|
||||
})
|
||||
erwarte_fehler(r, http=(401, 429), code=['invalid_credentials', 'locked_out'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user