Files
claude-regelwerk/.vault-tools/trading-plattform-pruefung/angriffe.py
T
Pascal MichelandClaude Opus 5 395642c480 Automatischer Push: 19 Datei(en) geändert
- .vault-tools/plan-kopie/LIESMICH.md
- .vault-tools/plan-kopie/rot_nachweis.py
- .vault-tools/plan-kopie/welle_vergleich.py
- .vault-tools/plan-kopie/welle_vergleich_treiber.py
- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/angriffe.py
- .vault-tools/trading-plattform-pruefung/browserlauf.py
- .vault-tools/trading-plattform-pruefung/browserlauf_zugang.py
- .vault-tools/trading-plattform-pruefung/c_eigenlauf.py
- .vault-tools/trading-plattform-pruefung/d_eigenlauf.py
- .vault-tools/trading-plattform-pruefung/handelstag_auswertung.py
- .vault-tools/trading-plattform-pruefung/ntfy_probe_server.py
- … und 7 weitere

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 04:00:01 +02:00

345 lines
17 KiB
Python

"""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 <basis-url> <ablageordner des probe-backends> [--ohne-bremse] [--ca <pem>]
--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)