"""Angriffsfaelle gegen ein laufendes Dashboard-Backend mit Anmeldung (Teil 10, Teil B). Koordinator-Pruefskript, nicht fuers Repo. Es beurteilt, ob ein LAUFENDES Backend die Zusagen der Spec zu Teil 10 haelt; es ist ein Fangnetz, kein Nachweis. Geltungsbereich (Herkunft jeder Erwartung: Projektvorgabe, Spec Teil 10, Abschnitt "Teil B"): - freie Pfade (/api/gesund, GET /api/sitzung), geschuetzte Pfade ohne Cookie 401, fremder Host 400, schreibende Anfrage ohne oder mit fremdem Origin 403 (auch mit gueltigem Cookie), /docs /redoc /openapi.json 404, Sicherheitskopfzeilen, einheitliche Ablehnung der Anmeldung, Cookie-Merkmale, Wiederholung eines TOTP-Codes, Abmeldung entwertet das Cookie, WebSocket ohne Cookie und mit fremdem Origin abgelehnt, Bremse (429 mit Retry-After, auch fuer die richtige Anmeldung), Anmeldeprotokoll ohne Passwort und Code, /api/paper mit Protokollpfad ausserhalb des Ordners. - seit 2026-09-18 abhaengig vom Schema der Basis-URL (Herkunft: Projektvorgabe, Spec Teil 10 Teil B und trading/dashboard/sicherheit.py: secure=tls am Cookie, Strict-Transport-Security nur mit TLS): unter https traegt das Sitzungscookie Secure und jede Antwort Strict-Transport-Security mit positivem max-age, und eine Klartext-Anfrage an denselben Port liefert keine HTTP-Antwort 200; unter http traegt das Cookie kein Secure und keine Antwort Strict-Transport-Security. Was es nicht sieht: ob das Zertifikat taugt (es vertraut der mit --ca genannten Datei, bei einem selbstsignierten Zertifikat also dem Zertifikat selbst), Protokollversionen und Chiffren von TLS, TLS-Terminierung vor dem Dashboard (dann sieht die Anwendung HTTP und setzt weder Secure noch HSTS -- das meldet dieser Lauf unter https zu Recht als Befund), DNS-Rebinding im echten Browser, Zeitverhalten der Passwortpruefung, alles hinter der Anmeldung ausser einem GET und einem POST je Fall, und ob die CSP die gebaute Oberflaeche wirklich traegt (dafuer der Browser-Lauf). Regressionsnachweis: gegen das alte Probe-Backend ohne Anmeldung (start_probe_backend.py) muss der Lauf mit Exit 2 enden und die Schutzfaelle einzeln rot zeigen. Fuer die https-Faelle: gegen ein TLS-Backend, dessen sicherheit.py weder Secure noch HSTS setzt (Mutant im Ziel), muessen genau diese Faelle rot sein. Aufruf (in WSL, Venv des Projekts): python angriffe.py [--ohne-bremse] [--ca ] --ca nennt die Zertifikatsdatei, der bei https vertraut wird (selbstsigniertes Zertifikat). Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt (auch das ist ein Befund). """ import base64 import hashlib import hmac import json import os import re import struct import sys import time from pathlib import Path import requests from websockets.exceptions import InvalidStatus from websockets.sync.client import connect befunde: list[str] = [] bestanden: list[str] = [] def pruefe(name: str, bedingung: bool, einzelheit: str = "") -> None: if bedingung: bestanden.append(name) print(f" ok {name}") else: befunde.append(f"{name}: {einzelheit}") print(f" ROT {name}: {einzelheit}") def totp(geheimnis: str, zeit_s: float) -> str: """Eigener Nachbau nach RFC 6238 / RFC 4226, unabhaengig vom Projektcode.""" schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) zaehler = int(zeit_s // 30) mac = hmac.new(schluessel, struct.pack(">Q", zaehler), hashlib.sha1).digest() versatz = mac[-1] & 0x0F zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF return f"{zahl % 1_000_000:06d}" def main() -> int: if len(sys.argv) < 3: print(__doc__) return 3 basis = sys.argv[1].rstrip("/") ablage = Path(sys.argv[2]) mit_bremse = "--ohne-bremse" not in sys.argv[3:] host = basis.split("://", 1)[1] origin = basis tls = basis.startswith("https://") if "--ca" in sys.argv[3:]: stelle = sys.argv.index("--ca") if stelle + 1 >= len(sys.argv) or not Path(sys.argv[stelle + 1]).is_file(): print("VORAUSSETZUNG FEHLT: --ca braucht eine vorhandene Zertifikatsdatei") return 3 # requests liest REQUESTS_CA_BUNDLE, der WebSocket-Client baut seinen Kontext ueber # ssl.create_default_context(), das SSL_CERT_FILE liest. Pruefen statt abschalten. os.environ["REQUESTS_CA_BUNDLE"] = sys.argv[stelle + 1] os.environ["SSL_CERT_FILE"] = sys.argv[stelle + 1] zugang_json = ablage / "zugang.json" if not zugang_json.is_file(): print(f"VORAUSSETZUNG FEHLT: {zugang_json} (Ablage des Probe-Backends)") return 3 zugang = json.loads(zugang_json.read_text(encoding="utf-8")) benutzer, passwort, geheimnis = zugang["benutzer"], zugang["passwort"], zugang["geheimnis"] # RFC-6238-Vektor gegen den eigenen Nachbau, bevor irgendetwas damit gemessen wird. if totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59) != "287082": print("VORAUSSETZUNG FEHLT: der eigene TOTP-Nachbau trifft den RFC-Vektor nicht") return 3 try: grund = requests.get(f"{basis}/api/gesund", timeout=5) except requests.RequestException as ausnahme: print(f"VORAUSSETZUNG FEHLT: Backend unter {basis} nicht erreichbar: {ausnahme!r}") return 3 print("== Grundlinie") pruefe("gesund ohne Cookie 200", grund.status_code == 200, f"HTTP {grund.status_code}") sitzung = requests.get(f"{basis}/api/sitzung", timeout=5) try: sitzung_json = sitzung.json() except ValueError: sitzung_json = {} pruefe( "sitzung ohne Cookie: pflicht, eingerichtet, nicht angemeldet", sitzung.status_code == 200 and sitzung_json.get("pflicht") is True and sitzung_json.get("eingerichtet") is True and sitzung_json.get("angemeldet") is False, f"HTTP {sitzung.status_code} {sitzung.text[:120]}", ) print("== Geschuetzte Pfade ohne Cookie") for pfad in ( "/api/uebersicht", "/api/agenten", "/api/bestand", "/api/laeufe", "/api/markt/datensaetze", "/api/paper", ): antwort = requests.get(f"{basis}{pfad}", timeout=10) pruefe(f"GET {pfad} ohne Cookie 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") print("== Fremder Host") for pfad in ("/", "/api/gesund", "/api/uebersicht"): antwort = requests.get(f"{basis}{pfad}", headers={"Host": "boese.example"}, timeout=5) pruefe(f"GET {pfad} mit fremdem Host 400", antwort.status_code == 400, f"HTTP {antwort.status_code}") print("== Schnittstellenbeschreibung") for pfad in ("/docs", "/redoc", "/openapi.json"): antwort = requests.get(f"{basis}{pfad}", timeout=5) pruefe(f"GET {pfad} 404", antwort.status_code == 404, f"HTTP {antwort.status_code}") print("== Kopfzeilen") for pfad in ("/", "/api/gesund"): kopf = requests.get(f"{basis}{pfad}", timeout=5).headers csp = kopf.get("content-security-policy", "") pruefe(f"{pfad}: CSP mit script-src 'self'", "script-src 'self'" in csp, csp[:80]) pruefe(f"{pfad}: CSP mit frame-ancestors 'none'", "frame-ancestors 'none'" in csp, csp[:80]) pruefe(f"{pfad}: X-Frame-Options DENY", kopf.get("x-frame-options") == "DENY", str(kopf.get("x-frame-options"))) pruefe(f"{pfad}: nosniff", kopf.get("x-content-type-options") == "nosniff", str(kopf.get("x-content-type-options"))) pruefe(f"{pfad}: Referrer-Policy", kopf.get("referrer-policy") == "no-referrer", str(kopf.get("referrer-policy"))) pruefe(f"{pfad}: kein Server-Kopf", "server" not in kopf, str(kopf.get("server"))) for pfad in ("/", "/api/gesund", "/api/uebersicht"): hsts = requests.get(f"{basis}{pfad}", timeout=5).headers.get("strict-transport-security") if tls: treffer = re.fullmatch(r"max-age=(\d+)(;.*)?", hsts or "") pruefe( f"{pfad}: Strict-Transport-Security mit positivem max-age (HTTPS)", treffer is not None and int(treffer.group(1)) > 0, str(hsts), ) else: pruefe(f"{pfad}: kein Strict-Transport-Security (HTTP)", hsts is None, str(hsts)) if tls: klartext = "http://" + host try: offen = requests.get(f"{klartext}/api/gesund", timeout=5) ergebnis_klartext = f"HTTP {offen.status_code}" except requests.RequestException as ausnahme: ergebnis_klartext = f"keine Antwort: {type(ausnahme).__name__}" pruefe("Klartext-Anfrage an den TLS-Port liefert kein 200", ergebnis_klartext != "HTTP 200", ergebnis_klartext) api_kopf = requests.get(f"{basis}/api/gesund", timeout=5).headers pruefe("/api/gesund: Cache-Control no-store", api_kopf.get("cache-control") == "no-store", str(api_kopf.get("cache-control"))) print("== Anmeldung: Origin und einheitliche Ablehnung") rumpf = {"benutzer": benutzer, "passwort": passwort, "code": totp(geheimnis, time.time())} ohne_origin = requests.post(f"{basis}/api/anmeldung", json=rumpf, timeout=10) pruefe("Anmeldung ohne Origin 403", ohne_origin.status_code == 403, f"HTTP {ohne_origin.status_code}") fremd = requests.post( f"{basis}/api/anmeldung", json=rumpf, headers={"Origin": "http://boese.example"}, timeout=10 ) pruefe("Anmeldung mit fremdem Origin 403", fremd.status_code == 403, f"HTTP {fremd.status_code}") kopf = {"Origin": origin} texte = set() for name, daten in ( ("falsches Passwort", {**rumpf, "passwort": "ganz-falsches-passwort"}), ("falscher Code", {**rumpf, "code": "000000" if rumpf["code"] != "000000" else "111111"}), ("unbekannter Benutzer", {**rumpf, "benutzer": "niemand"}), ): antwort = requests.post(f"{basis}/api/anmeldung", json=daten, headers=kopf, timeout=10) pruefe(f"Anmeldung, {name}: 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") texte.add(antwort.text) pruefe("alle drei Ablehnungen lauten gleich", len(texte) == 1, str(texte)[:200]) print("== Anmeldung: Erfolg, Cookie, Wiederholung") jetzt = time.time() code = totp(geheimnis, jetzt) sitz = requests.Session() gut = sitz.post(f"{basis}/api/anmeldung", json={**rumpf, "code": code}, headers=kopf, timeout=10) pruefe("richtige Anmeldung 200", gut.status_code == 200, f"HTTP {gut.status_code} {gut.text[:120]}") setzen = gut.headers.get("set-cookie", "") klein = setzen.lower() pruefe("Cookie tp_sitzung gesetzt", setzen.startswith("tp_sitzung="), setzen[:40]) pruefe("Cookie HttpOnly", "httponly" in klein, setzen[-80:]) pruefe("Cookie SameSite=Strict", "samesite=strict" in klein, setzen[-80:]) pruefe("Cookie Path=/", "path=/" in klein, setzen[-80:]) merkmale = [teil.strip() for teil in klein.split(";")] if tls: pruefe("Cookie mit Secure (HTTPS)", "secure" in merkmale, setzen[-80:]) else: pruefe("Cookie ohne Secure (HTTP)", "secure" not in merkmale, setzen[-80:]) wieder = requests.post(f"{basis}/api/anmeldung", json={**rumpf, "code": code}, headers=kopf, timeout=10) pruefe("derselbe Code ein zweites Mal 401", wieder.status_code == 401, f"HTTP {wieder.status_code}") print("== Mit Cookie") ueber = sitz.get(f"{basis}/api/uebersicht", timeout=15) pruefe("GET /api/uebersicht mit Cookie 200", ueber.status_code == 200, f"HTTP {ueber.status_code}") halt = sitz.post(f"{basis}/api/killswitch/anhalten", json={"grund": "angriffe.py"}, timeout=15) pruefe("POST mit Cookie, ohne Origin 403", halt.status_code == 403, f"HTTP {halt.status_code}") halt = sitz.post( f"{basis}/api/killswitch/anhalten", json={"grund": "angriffe.py"}, headers={"Origin": "http://boese.example"}, timeout=15, ) pruefe("POST mit Cookie, fremder Origin 403", halt.status_code == 403, f"HTTP {halt.status_code}") halt = sitz.post( f"{basis}/api/killswitch/anhalten", json={"grund": "angriffe.py"}, headers=kopf, timeout=15 ) pruefe( "POST mit Cookie und Origin kommt an (200 oder 409)", halt.status_code in (200, 409), f"HTTP {halt.status_code} {halt.text[:120]}", ) print("== /api/paper") paper = sitz.get(f"{basis}/api/paper", timeout=10) pruefe("GET /api/paper mit Cookie 200", paper.status_code == 200, f"HTTP {paper.status_code}") status_datei = ablage / "status.json" if status_datei.is_file() and paper.status_code == 200: daten = paper.json() pruefe("paper: Status und Zeilen geliefert", bool(daten.get("status")) and len(daten.get("zeilen", [])) > 0, str(daten)[:160]) original = status_datei.read_text(encoding="utf-8") verbogen = json.loads(original) verbogen["protokoll"] = str(ablage / "registry.db") status_datei.write_text(json.dumps(verbogen), encoding="utf-8") try: aussen = sitz.get(f"{basis}/api/paper", timeout=10).json() pruefe( "paper: Protokollpfad ausserhalb des Ordners liefert keine Zeilen, aber einen Hinweis", aussen.get("zeilen") == [] and bool(aussen.get("hinweis")), str(aussen)[:200], ) finally: status_datei.write_text(original, encoding="utf-8") else: print(" (kein Paper-Status in der Ablage; Fall uebersprungen — Backend mit 'mit-paperstatus' starten)") print("== WebSocket") ws_url = basis.replace("http", "ws", 1) + "/ws/uebersicht" cookie = "; ".join(f"{c.name}={c.value}" for c in sitz.cookies) def ws_versuch(kopfzeilen: dict[str, str], mit_origin: str | None) -> str: try: with connect(ws_url, additional_headers=kopfzeilen, origin=mit_origin, open_timeout=5) as ws: return "offen:" + str(ws.recv(timeout=10))[:40] except InvalidStatus as ausnahme: return f"abgelehnt:{ausnahme.response.status_code}" except Exception as ausnahme: # noqa: BLE001 - jede andere Ablehnung ist auch eine return f"abgelehnt:{ausnahme!r}"[:80] pruefe("WebSocket ohne Cookie abgelehnt", ws_versuch({}, origin).startswith("abgelehnt"), ws_versuch({}, origin)) ergebnis = ws_versuch({"Cookie": cookie}, "http://boese.example") pruefe("WebSocket mit Cookie, fremder Origin abgelehnt", ergebnis.startswith("abgelehnt"), ergebnis) ergebnis = ws_versuch({"Cookie": cookie}, origin) pruefe("WebSocket mit Cookie und Origin liefert einen Snapshot", ergebnis.startswith("offen:"), ergebnis) print("== Abmeldung") ab = sitz.post(f"{basis}/api/abmeldung", headers=kopf, timeout=10) pruefe("Abmeldung 204", ab.status_code == 204, f"HTTP {ab.status_code}") danach = requests.get(f"{basis}/api/uebersicht", headers={"Cookie": cookie}, timeout=10) pruefe("altes Cookie nach der Abmeldung 401", danach.status_code == 401, f"HTTP {danach.status_code}") if mit_bremse: print("== Bremse (sperrt diese Adresse; deshalb zuletzt)") status = [] for _ in range(12): antwort = requests.post( f"{basis}/api/anmeldung", json={**rumpf, "passwort": "ganz-falsches-passwort"}, headers=kopf, timeout=10, ) status.append(antwort.status_code) if antwort.status_code == 429: pruefe("429 traegt Retry-After", antwort.headers.get("retry-after", "").isdigit(), str(antwort.headers.get("retry-after"))) break pruefe("Bremse greift innerhalb von zwoelf Fehlversuchen", 429 in status, str(status)) # Ein frischer Zeitschritt, damit die Ablehnung nicht an der Wiederholung des Codes liegt. warten = 30 - (time.time() % 30) + 1 time.sleep(warten) richtig = requests.post( f"{basis}/api/anmeldung", json={**rumpf, "code": totp(geheimnis, time.time())}, headers=kopf, timeout=10, ) pruefe("gesperrt gilt auch fuer die richtige Anmeldung (429)", richtig.status_code == 429, f"HTTP {richtig.status_code}") print("== Anmeldeprotokoll") protokoll = ablage / "anmeldungen.jsonl" if not protokoll.is_file(): pruefe("Anmeldeprotokoll vorhanden", False, str(protokoll)) else: text = protokoll.read_text(encoding="utf-8") pruefe("Protokoll traegt das Passwort nicht", passwort not in text and "ganz-falsches-passwort" not in text, "Passwort im Protokoll") pruefe("Protokoll traegt den Code nicht", code not in text, "Code im Protokoll") ergebnisse = {json.loads(z).get("ergebnis") for z in text.splitlines() if z.strip()} pruefe("Protokoll kennt ok und falsch", {"ok", "falsch"} <= ergebnisse, str(ergebnisse)) print() print(f"{len(bestanden)} Faelle ok, {len(befunde)} Befund(e).") for befund in befunde: print(f"BEFUND: {befund}") return 2 if befunde else 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Pruefskripts ist selbst ein Befund for befund in befunde: print(f"BEFUND: {befund}") print(f"BEFUND: das Pruefskript ist abgebrochen: {ausnahme!r}") sys.exit(2)