From 3af3c263046909a09a16d63061babca5a7ca9657 Mon Sep 17 00:00:00 2001 From: Nicolas Gruber Date: Thu, 22 Oct 2026 10:04:25 +0200 Subject: [PATCH] Security-Seite: Reichweiten-/Access-Control-Check (nur Booleans+Anzahl, keine Werte) - Profil-Privinfelder, Foto-IDOR, LV-Plan, Directory, anonyme iCal, Klassenliste --- app/cis.py | 85 +++++++++++++++++++++++++++++++++++++ app/main.py | 26 ++++++++++++ app/static/app.js | 32 ++++++++++++++ app/static/style.css | 1 + app/templates/base.html | 1 + app/templates/security.html | 29 +++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 app/templates/security.html diff --git a/app/cis.py b/app/cis.py index f426b8c..76d98ba 100644 --- a/app/cis.py +++ b/app/cis.py @@ -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"] diff --git a/app/main.py b/app/main.py index 43968d0..be7285e 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ import hashlib import json import os +import random import threading import time import uuid @@ -454,6 +455,31 @@ def person_page(request: Request, uid: str): }) +@app.get("/security", response_class=HTMLResponse) +def security_page(request: Request): + return templates.TemplateResponse(request, "security.html", { + "request": request, "own_uid": OWN_UID, + }) + + +@app.get("/api/security/probe") +def api_security_probe(uid: str = Query("")): + """Access-Control-Report: nur Booleans/Anzahl - keine Personenwerte.""" + meta = _meta() + label = current_semester(meta["semesters"]) + s = next((x for x in meta["semesters"] if x["label"] == label), meta["semesters"][0]) + target = uid.strip() + if not target: + pool = [b["uid"] for b in client.benutzer_all() + if b["uid"] != OWN_UID and b["uid"].isdigit()] + if not pool: + return JSONResponse({"error": "kein Ziel-Person aus Directory"}, status_code=502) + target = random.choice(pool) + if target == OWN_UID: + return JSONResponse({"error": "Eigenes Profil ist kein fremder Zugriff"}, status_code=400) + checks = client.privacy_probe(target, OWN_UID, s) + return {"target": target, "semester": label, "checks": checks} + @app.get("/api/person_id") def api_person_id(uid: str = Query(...)): return {"uid": uid, "person_id": client.person_id_for(uid)} diff --git a/app/static/app.js b/app/static/app.js index 1355e8c..eaaf9b5 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -519,6 +519,38 @@ function initUidSuggest() { }); } +/* ================= Security-Check ================= */ +async function runProbe() { + const btn = $("#probe-btn"); + const st = $("#probe-status"); + const inp = $("#probe-uid"); + const uid = inp ? inp.value.trim() : ""; + btn.disabled = true; + st.textContent = "Läuft… 8–10 Requests gegen das CIS, ein paar Sekunden."; + try { + const r = await fetch("/api/security/probe" + (uid ? "?uid=" + encodeURIComponent(uid) : "")); + const j = await r.json(); + if (j.error) { st.textContent = j.error; return; } + st.textContent = ""; + document.querySelector("#probe-table tbody").innerHTML = j.checks.map((c, i) => ` + ${i + 1} + ${esc(c.name)} + ${c.ok ? 'REICHWEITE' + : 'GESCHÜTZT'} + ${esc(c.info || "")} + `).join(""); + $("#probe-table").style.display = ""; + const tt = $("#probe-target"); + tt.innerHTML = `Ziel dieses Laufs: ${esc(j.target)} · ` + + `Semester ${esc(j.semester)} · Beim nächsten Klick neues Zufallsziel.`; + tt.style.display = "block"; + } catch (e) { + st.textContent = "Fehler: " + e.message; + } finally { + btn.disabled = false; + } +} + /* ================= boot ================= */ document.addEventListener("DOMContentLoaded", () => { if (window.__PLAN_DATA__) initPlan(); diff --git a/app/static/style.css b/app/static/style.css index 49ee851..89216bd 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -239,6 +239,7 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; } .person-name { margin: 0 0 10px; font-size: 26px; color: var(--ink); } .person-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } .badge-green { background: #dcfce7; color: var(--green); } +.badge-red { background: #fee2e2; color: var(--red); } .person-kv { width: 100%; border-collapse: collapse; margin-bottom: 18px; } .person-kv td { padding: 7px 10px 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; } .person-kv td:first-child { color: var(--muted); width: 180px; font-weight: 600; } diff --git a/app/templates/base.html b/app/templates/base.html index c45ae3b..ca48ac0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -19,6 +19,7 @@ Studiengänge Namenslisten Fotos + Security Start diff --git a/app/templates/security.html b/app/templates/security.html new file mode 100644 index 0000000..08a6d8b --- /dev/null +++ b/app/templates/security.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Security-Check{% endblock %} +{% block content %} +

Reichweiten-Check + Access Control +

+ +
+

+ Prüft, was ein normaler Studenten-Login (dein Account) von fremden Personen + abrufen kann. Angezeigt werden ausschließlich Booleans und Anzahlen – + keine Feldwerte, keine Speicherung. Grün = vom CIS sauber geschützt, + Rot = Daten liegen mit diesem Account offen. +

+
+
+ + +
+ +
+

+ + + + + +
+{% endblock %}