- .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]>
345 lines
17 KiB
Python
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)
|