Security-Seite: Reichweiten-/Access-Control-Check (nur Booleans+Anzahl, keine Werte) - Profil-Privinfelder, Foto-IDOR, LV-Plan, Directory, anonyme iCal, Klassenliste

This commit is contained in:
Nicolas Gruber
2026-10-22 10:04:25 +02:00
parent 5b036024ec
commit 3af3c26304
6 changed files with 174 additions and 0 deletions
+85
View File
@@ -420,6 +420,91 @@ class CISClient:
self._profile_cache[uid] = (time.time(), prof)
return prof
# ---------- Reichweiten-Check (Security) ----------
PRIVATE_MARKERS = ("Geburtsdatum:", "Geburtsort:", "Hauptwohnsitz:",
"Telefonnummer", "FH-Ausweis Status")
def privacy_probe(self, uid: str, own_uid: str, semester: dict) -> list[dict]:
"""Access-Control-Check gegen EINE fremde Person.
Returns nur Booleans/Anzahlen - niemals Feldwerte."""
checks: list[dict] = []
def add(name: str, ok, info: str = ""):
checks.append({"name": name, "ok": bool(ok), "info": info})
users = self.benutzer_all()
add("Verzeichnis Autocomplete", len(users) > 500,
f"{len(users)} Personen (Name+UID) mit 5 Requests")
prof = self.profile(uid) or {}
nachname = prof.get("nachname") or ""
html = ""
try:
html = self._get(f"{self.base}/private/profile/index.php?uid={uid}")
except requests.HTTPError:
pass
t = re.sub(r"<[^>]+>", " ", html) if html else ""
fremd = bool(nachname) and nachname in t and uid != own_uid
priv = [m.rstrip(":") for m in self.PRIVATE_MARKERS if m in t] if fremd else []
add("Fremde Privinfelder im Profil", priv,
("LEAK: " + ", ".join(priv)) if priv else
"gefiltert (nur oeffentliche Felder)" if fremd else "Ziel nicht ladbar")
pid = self.person_id_for(uid)
photo_ok, photo_info = False, "kein person_id"
if pid:
try:
r = self.s.get(f"{self.base}/public/bild.php?src=person&person_id={pid}",
timeout=30)
ct = (r.headers.get("Content-Type") or "").split(";")[0]
photo_ok = r.status_code == 200 and ct.startswith("image/")
photo_info = f"HTTP {r.status_code}, {ct}, {len(r.content)} Bytes"
except requests.RequestException as e:
photo_info = str(e)
add("Profilfoto via person_id (IDOR)", photo_ok, photo_info)
trap_ok, trap_info = False, "-"
if pid:
try:
h2 = self._get(f"{self.base}/private/profile/index.php?person_id={pid}")
t2 = re.sub(r"<[^>]+>", " ", h2)
trap_ok = bool(nachname) and nachname in t2 and own_uid not in t2
trap_info = ("LEAK: fremdes Profil" if trap_ok else
"Parameter ignoriert -> eigenes Profil")
except requests.HTTPError as e:
trap_info = f"HTTP {e}"
add("Falle: person_id statt uid uebergeben", trap_ok, trap_info)
b, e = semester["begin"], semester["ende"]
try:
evs = self.student_events(uid, b, e)
except Exception:
evs = []
add("Fremder LV-Plan (HTML/iCal)", len(evs) > 0,
f"{len(evs)} Termine im aktuellen Semester")
try:
r = requests.get(self._ical_url(self._ical_student_params(uid, b, e)),
timeout=20, allow_redirects=False)
body = r.text if r.status_code == 200 else ""
anon = r.status_code == 200 and "BEGIN:VCALENDAR" in body
add("iCal OHNE Login (anonym)", anon,
f"HTTP {r.status_code} {'-> VCALENDAR' if anon else '(Auth noetig)'}")
except requests.RequestException as ex:
add("iCal OHNE Login (anonym)", False, str(ex))
sem, ver = prof.get("semester", ""), prof.get("verband", "")
kz = next((x["kz"] for x in self.meta()["stgs"] if x["name"] == prof.get("stg")), "")
if kz and sem and ver:
try:
n = len(self.namensliste(kz, sem, ver))
except Exception:
n = 0
add("Klassenliste Name+Mail", n > 0, f"{n} Personen (1 Request)")
else:
add("Klassenliste Name+Mail", False, "n/a (Ziel ohne StG-Badaten)")
return checks
# ---------- Verbände pro StG/Semester ----------
def verbands_for(self, kz: str, sem: str) -> list[str]:
fallback = ["V", "B", "G", "A", "C", "D", "E", "F"]