Erste Produktivversion
This commit is contained in:
88
tools/check-routes.py
Normal file
88
tools/check-routes.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Listet alle Endpunkte samt ihrer Berechtigungsabhängigkeiten.
|
||||
|
||||
python3 tools/check-routes.py
|
||||
|
||||
Findet Endpunkte, die weder eine Abhängigkeit mit Berechtigungsprüfung
|
||||
haben noch ausdrücklich als offen eingetragen sind. Damit fällt auf, wenn
|
||||
beim Hinzufügen einer Route die Absicherung vergessen wurde - was beim
|
||||
Schreiben leicht passiert, weil FastAPI das nicht anmahnt.
|
||||
|
||||
Rein statisch: Zwei Fälle kann das Skript nicht sehen und sie sind unten
|
||||
als Ausnahmen vermerkt - Endpunkte, die ihre Prüfung im Rumpf machen
|
||||
(DELETE auf Mitgliedschaften: Eigentümer oder man selbst) und solche,
|
||||
die über einen Token im Pfad geschützt sind.
|
||||
"""
|
||||
import ast, pathlib, re
|
||||
|
||||
GUARDS = {
|
||||
"CurrentUser": "angemeldet",
|
||||
"VerifiedUser": "bestätigt",
|
||||
"AdminUser": "ADMIN",
|
||||
"ReadableList": "Listen-Leser",
|
||||
"EditableList": "Listen-Bearbeiter",
|
||||
"OwnedList": "Listen-EIGENTÜMER",
|
||||
"SharableList": "Teilen-Berechtigt",
|
||||
}
|
||||
|
||||
# Endpunkte, die bewusst ohne Anmeldung erreichbar sind
|
||||
INTENDED_PUBLIC = {
|
||||
"/api/auth/register", "/api/auth/login", "/api/auth/verify",
|
||||
"/api/auth/password/reset-request", "/api/auth/password/reset",
|
||||
# Willkommensstrecke: Wer den Link oeffnet, HAT noch kein Passwort
|
||||
# und kann sich deshalb nicht anmelden. Der Token ist der Nachweis.
|
||||
"/api/auth/welcome/{token}", "/api/auth/welcome/complete",
|
||||
# Bestaetigung einer Adressaenderung - ebenfalls per Token, weil der
|
||||
# Klick aus dem Postfach kommt.
|
||||
"/api/auth/email-change/{token}",
|
||||
"/api/config", "/manifest.webmanifest", "/api/push/config",
|
||||
"/healthz", "/readyz",
|
||||
}
|
||||
# Öffentlich, aber durch einen Token im Pfad geschützt
|
||||
TOKEN_GUARDED = ("/api/public/",)
|
||||
|
||||
rows = []
|
||||
for path in sorted(pathlib.Path("backend/app/routers").glob("*.py")):
|
||||
src = path.read_text()
|
||||
prefix = ""
|
||||
m = re.search(r'APIRouter\((?:[^)]*?)prefix="([^"]*)"', src, re.S)
|
||||
if m:
|
||||
prefix = m.group(1)
|
||||
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
|
||||
continue
|
||||
if dec.func.attr.upper() not in {"GET","POST","PUT","PATCH","DELETE"}:
|
||||
continue
|
||||
method = dec.func.attr.upper()
|
||||
route = prefix + (dec.args[0].value if dec.args else "?")
|
||||
|
||||
guards = []
|
||||
for arg in node.args.args + node.args.kwonlyargs:
|
||||
ann = ast.unparse(arg.annotation) if arg.annotation else ""
|
||||
for name, label in GUARDS.items():
|
||||
if ann == name:
|
||||
guards.append(label)
|
||||
rows.append((route, method, guards, path.name))
|
||||
|
||||
print(f"{'Pfad':52} {'Methode':7} Schutz")
|
||||
print("-" * 100)
|
||||
problems = 0
|
||||
for route, method, guards, file in sorted(rows):
|
||||
if guards:
|
||||
mark = ", ".join(guards)
|
||||
elif route in INTENDED_PUBLIC:
|
||||
mark = "offen (gewollt)"
|
||||
elif route.startswith(TOKEN_GUARDED):
|
||||
mark = "Token im Pfad"
|
||||
else:
|
||||
mark = ">>> KEIN SCHUTZ <<<"
|
||||
problems += 1
|
||||
print(f"{route:52} {method:7} {mark}")
|
||||
|
||||
print("-" * 100)
|
||||
print(f"{len(rows)} Endpunkte, {problems} ohne erkennbaren Schutz")
|
||||
Reference in New Issue
Block a user