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>
478 lines
18 KiB
Python
478 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Die Regel, welche Authenticatoren zugelassen sind.
|
|
|
|
WAS HIER GEPRUEFT WIRD - UND WARUM GERADE DAS
|
|
|
|
Der Anlass war ein Befund aus dem Betrieb: Ein Passwortverwalter liess sich
|
|
als zweiter Faktor registrieren, obwohl in den Registrierungsvorgaben
|
|
authenticatorAttachment = "cross-platform" stand. Die Angabe steuert nur, was
|
|
der Browser anbietet; sie ist keine Pruefung. Was traegt, ist die Attestation.
|
|
|
|
Der entscheidende Fall laesst sich ohne Hardware nachstellen: Ein
|
|
Authenticator, der "fmt": "none" schickt - also genau das, was ein
|
|
Passwortverwalter oder ein in das Betriebssystem eingebauter Passkey liefert -
|
|
muss unter der Regel abgewiesen werden und ohne sie durchkommen. Dazu braucht
|
|
es keine Kryptografie: Bei einer Registrierung mit "none" prueft der Server
|
|
keine Signatur, er liest den oeffentlichen Schluessel und legt ihn ab. Deshalb
|
|
kommt diese Reihe mit der Standardbibliothek aus.
|
|
|
|
WAS HIER NICHT GEPRUEFT WIRD
|
|
|
|
Der Gegenfall - ein echter Yubikey, der eine gueltige Kette vorlegt - laesst
|
|
sich von hier aus nicht erzeugen; dafuer braeuchte man Yubicos privaten
|
|
Wurzelschluessel, und genau darauf beruht die ganze Aussage. Ebenso fehlt der
|
|
Fall "packed mit selbst ausgestelltem Zertifikat", der belegt, dass ein x5c
|
|
allein nicht genuegt: Er verlangt eine echte Unterschrift ueber eine
|
|
Herausforderung, die erst zur Laufzeit feststeht, und damit eine
|
|
Krypto-Bibliothek. Beides laeuft in einem eigenen Werkzeug ausserhalb dieser
|
|
Suite.
|
|
|
|
Die Reihe raeumt am Ende auf und stellt die Regel auf "off" zurueck. Ohne das
|
|
liefe die naechste Reihe gegen eine Anlage, die Hardware verlangt - und
|
|
scheiterte an etwas, das sie gar nicht prueft.
|
|
"""
|
|
|
|
import base64
|
|
import http.cookiejar
|
|
import json
|
|
import re
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from harness import (test, erwarte, erwarte_status, ueberspringen, Fehlschlag)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ein oeffentlicher P-256-Punkt, fest eingebaut
|
|
# ---------------------------------------------------------------------------
|
|
# Warum fest und nicht erzeugt: Ein Punkt muss auf der Kurve liegen, sonst
|
|
# lehnt der Server schon beim Umwandeln nach PEM ab - und der Testfall
|
|
# scheiterte dann aus dem falschen Grund. Einen gueltigen Punkt zu WUERFELN
|
|
# verlangt dieselbe Bibliothek, die diese Suite nicht haben will. Also steht
|
|
# hier einer, der nachweislich auf P-256 liegt.
|
|
#
|
|
# Der zugehoerige private Schluessel ist bewusst nirgends abgelegt. Er wird
|
|
# nicht gebraucht: Bei einer Registrierung mit "none" unterschreibt niemand.
|
|
# Ein Schluessel, den man nicht braucht, ist ein Schluessel, der nicht
|
|
# verloren gehen kann.
|
|
PUBKEY_X = bytes.fromhex('6ee18ffb6fb32a97ac63039c118a9d507fb4624761df549a57b0f97457f85ca8')
|
|
PUBKEY_Y = bytes.fromhex('6291e3318ceadce692fd39d1822ffef9255f5eeb916b634694548c5d452b03cd')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CBOR-Schreiber, gerade gross genug fuer ein Attestationsobjekt
|
|
# ---------------------------------------------------------------------------
|
|
def _kopf(major, wert):
|
|
if wert < 24:
|
|
return bytes([(major << 5) | wert])
|
|
if wert < 0x100:
|
|
return bytes([(major << 5) | 24, wert])
|
|
if wert < 0x10000:
|
|
return bytes([(major << 5) | 25]) + wert.to_bytes(2, 'big')
|
|
return bytes([(major << 5) | 26]) + wert.to_bytes(4, 'big')
|
|
|
|
|
|
def _cbor(wert):
|
|
if isinstance(wert, bytes):
|
|
return _kopf(2, len(wert)) + wert
|
|
if isinstance(wert, str):
|
|
roh = wert.encode('utf-8')
|
|
return _kopf(3, len(roh)) + roh
|
|
if isinstance(wert, bool):
|
|
return bytes([0xF5 if wert else 0xF4])
|
|
if isinstance(wert, int):
|
|
if wert >= 0:
|
|
return _kopf(0, wert)
|
|
return _kopf(1, -wert - 1)
|
|
if isinstance(wert, list):
|
|
return _kopf(4, len(wert)) + b''.join(_cbor(x) for x in wert)
|
|
if isinstance(wert, dict):
|
|
# Die Reihenfolge bleibt, wie sie eingetragen wurde. Der Server liest
|
|
# die Abbildung als Ganzes; eine kanonische Sortierung braucht er
|
|
# nicht, und sie nachzubilden waere Aufwand ohne Wirkung.
|
|
out = _kopf(5, len(wert))
|
|
for k, v in wert.items():
|
|
out += _cbor(k) + _cbor(v)
|
|
return out
|
|
raise TypeError('CBOR: %r' % type(wert))
|
|
|
|
|
|
def _b64u(roh):
|
|
return base64.urlsafe_b64encode(roh).decode('ascii').rstrip('=')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ein Sitzungsclient - die Suite selbst arbeitet mit Token, hier braucht es
|
|
# ein Cookie
|
|
# ---------------------------------------------------------------------------
|
|
class Sitzung:
|
|
def __init__(self, cfg):
|
|
self.basis = cfg['base_url'].rstrip('/')
|
|
self.basic = cfg.get('basic_auth') or {}
|
|
jar = http.cookiejar.CookieJar()
|
|
self.opener = urllib.request.build_opener(
|
|
urllib.request.HTTPCookieProcessor(jar),
|
|
urllib.request.HTTPSHandler(context=ssl.create_default_context()),
|
|
)
|
|
self.csrf = None
|
|
|
|
def _kopf(self, extra=None):
|
|
k = {'User-Agent': 'dc-e2e/1.0', 'Accept': 'text/html,application/json'}
|
|
if self.basic.get('user'):
|
|
roh = ('%s:%s' % (self.basic['user'], self.basic['pass'])).encode()
|
|
k['Authorization'] = 'Basic ' + base64.b64encode(roh).decode('ascii')
|
|
k.update(extra or {})
|
|
return k
|
|
|
|
def hole(self, pfad):
|
|
req = urllib.request.Request(self.basis + pfad, headers=self._kopf())
|
|
try:
|
|
with self.opener.open(req, timeout=45) as r:
|
|
text = r.read().decode('utf-8', 'replace')
|
|
except urllib.error.HTTPError as e:
|
|
text = e.read().decode('utf-8', 'replace')
|
|
treffer = re.search(r'name="csrf_token"\s+value="([^"]+)"', text)
|
|
if treffer:
|
|
self.csrf = treffer.group(1)
|
|
return text
|
|
|
|
def formular(self, pfad, felder):
|
|
daten = dict(felder)
|
|
if self.csrf:
|
|
daten['csrf_token'] = self.csrf
|
|
req = urllib.request.Request(
|
|
self.basis + pfad,
|
|
data=urllib.parse.urlencode(daten).encode(),
|
|
headers=self._kopf({'Content-Type': 'application/x-www-form-urlencoded'}),
|
|
method='POST')
|
|
try:
|
|
with self.opener.open(req, timeout=45) as r:
|
|
return r.status, r.read().decode('utf-8', 'replace')
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read().decode('utf-8', 'replace')
|
|
|
|
def json_post(self, pfad, rumpf):
|
|
req = urllib.request.Request(
|
|
self.basis + pfad,
|
|
data=json.dumps(rumpf).encode(),
|
|
headers=self._kopf({'Content-Type': 'application/json',
|
|
'X-CSRF-Token': self.csrf or ''}),
|
|
method='POST')
|
|
try:
|
|
with self.opener.open(req, timeout=45) as r:
|
|
return r.status, json.loads(r.read().decode('utf-8', 'replace'))
|
|
except urllib.error.HTTPError as e:
|
|
roh = e.read().decode('utf-8', 'replace')
|
|
try:
|
|
return e.code, json.loads(roh)
|
|
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', {
|
|
'username': benutzer,
|
|
'password': passwort,
|
|
})
|
|
# Nach der Anmeldung steht das Token der Sitzung in index.php - das
|
|
# aus dem Anmeldeformular gilt fuer die alte Sitzungskennung nicht
|
|
# mehr.
|
|
self.hole('/index.php')
|
|
return status
|
|
|
|
|
|
def _schluessel_loeschen(ctx):
|
|
"""
|
|
Raeumt die Hardwarekeys des Testkontos ab.
|
|
|
|
Notwendig, weil diese Reihe selbst einen registriert - und ein Konto mit
|
|
zweitem Faktor laesst sich mit dem Passwort allein nicht mehr anmelden.
|
|
Ohne dieses Aufraeumen liefe der zweite Lauf gegen den Zwischenzustand
|
|
und meldete "Anmeldung erforderlich", was nach einem Fehler der Anwendung
|
|
aussieht und keiner ist.
|
|
"""
|
|
benutzer = (ctx.cfg.get('admin') or {}).get('user') or 'admin'
|
|
try:
|
|
ctx.testctl('clear_credentials', username=benutzer)
|
|
except Exception:
|
|
# Aeltere Testinstanz ohne diese Aktion. Dann bleibt der Lauf auf das
|
|
# Zuruecksetzen angewiesen - gemeldet wird es beim Anmelden.
|
|
pass
|
|
|
|
|
|
def _sitzung(ctx):
|
|
cfg = ctx.cfg
|
|
if not (cfg.get('admin') or {}).get('user'):
|
|
ueberspringen('In der Konfiguration steht kein Administratorkonto.')
|
|
|
|
_schluessel_loeschen(ctx)
|
|
|
|
s = Sitzung(cfg)
|
|
s.anmelden(cfg['admin']['user'], cfg['admin']['pass'])
|
|
|
|
if s.csrf is None:
|
|
ueberspringen('Anmeldung am WebUI nicht moeglich - kein CSRF-Token erhalten.')
|
|
|
|
return s
|
|
|
|
|
|
def _regel_setzen(s, stufe, aaguids=''):
|
|
return s.formular('/index.php', {
|
|
'action': 'save_webauthn_policy',
|
|
'_return': 'sub-system-users',
|
|
'policy_level': stufe,
|
|
'policy_aaguids': aaguids,
|
|
})
|
|
|
|
|
|
def _registrieren(s, bezeichnung):
|
|
"""Eine Registrierung mit fmt "none" - wie ein Passwortverwalter sie liefert."""
|
|
status, vorgabe = s.json_post('/api/auth/v1/?action=register_options', {})
|
|
if status != 200:
|
|
raise Fehlschlag('register_options antwortete %d: %s' % (status, vorgabe))
|
|
|
|
pk = vorgabe['publicKey']
|
|
herausforderung = pk['challenge']
|
|
rp_id = pk['rp']['id']
|
|
|
|
import hashlib
|
|
import os
|
|
|
|
kennung = os.urandom(32)
|
|
|
|
cose = {1: 2, 3: -7, -1: 1, -2: PUBKEY_X, -3: PUBKEY_Y}
|
|
|
|
auth = hashlib.sha256(rp_id.encode()).digest()
|
|
auth += bytes([0x41]) # UP | AT
|
|
auth += (0).to_bytes(4, 'big') # Zaehler
|
|
auth += b'\x00' * 16 # aaguid: bei "none" immer null
|
|
auth += len(kennung).to_bytes(2, 'big')
|
|
auth += kennung
|
|
auth += _cbor(cose)
|
|
|
|
client = json.dumps({
|
|
'type': 'webauthn.create',
|
|
'challenge': herausforderung,
|
|
'origin': s.basis,
|
|
'crossOrigin': False,
|
|
}).encode()
|
|
|
|
attestation = _cbor({'fmt': 'none', 'attStmt': {}, 'authData': auth})
|
|
|
|
return s.json_post('/api/auth/v1/?action=register_verify', {
|
|
'clientDataJSON': _b64u(client),
|
|
'attestationObject': _b64u(attestation),
|
|
'label': bezeichnung,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Die Faelle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@test('hardware', 'Ohne Regel wird ein Software-Authenticator aufgenommen')
|
|
def t_ohne_regel(ctx):
|
|
"""
|
|
Der Ausgangszustand. Er ist zugleich der Befund, um den es geht: Ein
|
|
Authenticator ohne jeden Herkunftsnachweis kommt durch.
|
|
"""
|
|
s = _sitzung(ctx)
|
|
ctx['hw_sitzung'] = s
|
|
|
|
status, _ = _regel_setzen(s, 'off')
|
|
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "off" stellen (%s).' % status)
|
|
|
|
status, antwort = _registrieren(s, 'Testschluessel ohne Nachweis')
|
|
|
|
erwarte(status == 200,
|
|
'Ohne Regel muss die Aufnahme gelingen, war %d: %s' % (status, antwort))
|
|
erwarte(antwort.get('attested') is False,
|
|
'Ein Schluessel ohne Attestation darf nicht als Hardware gelten: %s' % antwort)
|
|
|
|
|
|
@test('hardware', 'Mit Regel wird derselbe Authenticator abgewiesen')
|
|
def t_mit_regel(ctx):
|
|
"""
|
|
Derselbe Vorgang, dieselben Bytes - nur die Regel steht anders. Genau
|
|
diese Gegenueberstellung ist die Aussage der Funktion.
|
|
"""
|
|
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
|
|
|
status, _ = _regel_setzen(s, 'register')
|
|
erwarte(status in (200, 302, 303), 'Regel liess sich nicht auf "register" stellen (%s).' % status)
|
|
|
|
status, antwort = _registrieren(s, 'Testschluessel unter Regel')
|
|
|
|
erwarte(status == 403,
|
|
'Unter der Regel muss die Aufnahme scheitern, war %d: %s' % (status, antwort))
|
|
|
|
# Die Fehlerhuelle der API traegt den Code eine Ebene tiefer. Hier beide
|
|
# Formen zu lesen ist keine Nachlaessigkeit: Der Testfall soll den
|
|
# Zustand der Anwendung melden und nicht die Form ihrer Antwort.
|
|
huelle = antwort.get('error')
|
|
code = huelle.get('code') if isinstance(huelle, dict) else huelle
|
|
erwarte(code == 'policy_rejected',
|
|
'Erwartet wurde policy_rejected, kam: %s' % antwort)
|
|
|
|
# Die Meldung muss den Grund nennen. Ein blosses "abgelehnt" liesse
|
|
# offen, ob der Schluessel kaputt ist oder die Regel greift - und das ist
|
|
# der Unterschied zwischen "anderen Schluessel holen" und "Einstellung
|
|
# pruefen".
|
|
text = (huelle.get('message') if isinstance(huelle, dict) else '') or ''
|
|
erwarte('attestation' in text.lower() or 'hardware' in text.lower(),
|
|
'Die Ablehnung nennt den Grund nicht: %s' % text)
|
|
|
|
|
|
@test('hardware', 'Die strenge Stufe sperrt sich nicht selbst aus')
|
|
def t_strict_bewacht(ctx):
|
|
"""
|
|
Nach den beiden Faellen oben hat das Konto keinen attestierten Schluessel.
|
|
"strict" wuerde die eigene Anmeldung sperren - und muss deshalb abgelehnt
|
|
werden, statt sie zu sperren.
|
|
"""
|
|
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
|
|
|
status, _ = _regel_setzen(s, 'strict')
|
|
erwarte(status in (200, 302, 303), 'Unerwarteter Status beim Umschalten (%s).' % status)
|
|
|
|
# Die Ablehnung zeigt sich daran, dass die Stufe NICHT uebernommen wurde.
|
|
# Das ist die Pruefung, auf die es ankommt: Eine Meldung ohne Wirkung
|
|
# waere schlimmer als gar keine.
|
|
seite = s.hole('/index.php')
|
|
treffer = re.search(
|
|
r'name="policy_level"\s+value="strict"[^>]*\schecked', seite)
|
|
erwarte(treffer is None,
|
|
'Die Stufe "strict" wurde uebernommen, obwohl kein attestierter '
|
|
'Schluessel vorliegt - das sperrt den eigenen Zugang aus.')
|
|
|
|
|
|
@test('hardware', 'Eine unbrauchbare Modellliste wird abgewiesen')
|
|
def t_allowlist_pruefung(ctx):
|
|
"""
|
|
Eine Geraetekennung, die keine ist, darf nicht stillschweigend als leere
|
|
Liste durchgehen - das hiesse "alles erlaubt", wo jemand "nur dieses
|
|
Modell" gemeint hat.
|
|
"""
|
|
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
|
|
|
status, _ = _regel_setzen(s, 'register', aaguids='kein-gueltiges-geraet')
|
|
erwarte(status in (200, 302, 303), 'Unerwarteter Status (%s).' % status)
|
|
|
|
seite = s.hole('/index.php')
|
|
erwarte('kein-gueltiges-geraet' not in seite,
|
|
'Die unbrauchbare Angabe wurde gespeichert.')
|
|
|
|
|
|
@test('hardware', 'Aufraeumen: Regel steht wieder auf "off"')
|
|
def t_aufraeumen(ctx):
|
|
"""
|
|
Kein Test im eigentlichen Sinn, sondern die Rueckgabe eines geliehenen
|
|
Zustands. Bliebe die Regel stehen, liefe die naechste Reihe gegen eine
|
|
Anlage, die Hardware verlangt.
|
|
"""
|
|
s = ctx.get('hw_sitzung') or _sitzung(ctx)
|
|
|
|
status, _ = _regel_setzen(s, 'off')
|
|
erwarte(status in (200, 302, 303), 'Regel liess sich nicht zuruecksetzen (%s).' % status)
|
|
|
|
seite = s.hole('/index.php')
|
|
treffer = re.search(r'name="policy_level"\s+value="off"[^>]*\schecked', seite)
|
|
erwarte(treffer is not None, 'Die Regel steht nicht wieder auf "off".')
|
|
|
|
# Der in dieser Reihe angelegte Schluessel muss ebenfalls weg. Bliebe er
|
|
# 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)
|