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) self._profile_cache[uid] = (time.time(), prof)
return 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 ---------- # ---------- Verbände pro StG/Semester ----------
def verbands_for(self, kz: str, sem: str) -> list[str]: def verbands_for(self, kz: str, sem: str) -> list[str]:
fallback = ["V", "B", "G", "A", "C", "D", "E", "F"] fallback = ["V", "B", "G", "A", "C", "D", "E", "F"]
+26
View File
@@ -2,6 +2,7 @@
import hashlib import hashlib
import json import json
import os import os
import random
import threading import threading
import time import time
import uuid 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") @app.get("/api/person_id")
def api_person_id(uid: str = Query(...)): def api_person_id(uid: str = Query(...)):
return {"uid": uid, "person_id": client.person_id_for(uid)} return {"uid": uid, "person_id": client.person_id_for(uid)}
+32
View File
@@ -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) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(c.name)}</td>
<td>${c.ok ? '<span class="badge badge-red">REICHWEITE</span>'
: '<span class="badge badge-green">GESCHÜTZT</span>'}</td>
<td>${esc(c.info || "")}</td>
</tr>`).join("");
$("#probe-table").style.display = "";
const tt = $("#probe-target");
tt.innerHTML = `Ziel dieses Laufs: <span class="mono">${esc(j.target)}</span> · ` +
`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 ================= */ /* ================= boot ================= */
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (window.__PLAN_DATA__) initPlan(); if (window.__PLAN_DATA__) initPlan();
+1
View File
@@ -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-name { margin: 0 0 10px; font-size: 26px; color: var(--ink); }
.person-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } .person-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.badge-green { background: #dcfce7; color: var(--green); } .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 { 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 { 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; } .person-kv td:first-child { color: var(--muted); width: 180px; font-weight: 600; }
+1
View File
@@ -19,6 +19,7 @@
<a href="/stg" class="{{ 'active' if path.startswith('/stg') }}">Studiengänge</a> <a href="/stg" class="{{ 'active' if path.startswith('/stg') }}">Studiengänge</a>
<a href="/liste" class="{{ 'active' if path.startswith('/liste') }}">Namenslisten</a> <a href="/liste" class="{{ 'active' if path.startswith('/liste') }}">Namenslisten</a>
<a href="/fotos" class="{{ 'active' if path.startswith('/fotos') }}">Fotos</a> <a href="/fotos" class="{{ 'active' if path.startswith('/fotos') }}">Fotos</a>
<a href="/security" class="{{ 'active' if path.startswith('/security') }}">Security</a>
<a href="/" class="{{ 'active' if path == '/' }}">Start</a> <a href="/" class="{{ 'active' if path == '/' }}">Start</a>
</nav> </nav>
</div> </div>
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Security-Check{% endblock %}
{% block content %}
<h1 class="page-title">Reichweiten-Check
<span class="badge badge-muted">Access Control</span>
</h1>
<div class="card">
<p class="hint" style="display:block">
Prüft, was ein <b>normaler Studenten-Login</b> (dein Account) von fremden Personen
abrufen kann. Angezeigt werden ausschließlich <b>Booleans und Anzahlen</b> –
keine Feldwerte, keine Speicherung. Grün = vom CIS sauber geschützt,
Rot = Daten liegen mit diesem Account offen.
</p>
<div class="form-row">
<div class="field">
<label>Ziel (optional)</label>
<input type="text" id="probe-uid" placeholder="leer = Zufallsstudent">
</div>
<button class="btn btn-primary" id="probe-btn" onclick="runProbe()">Check starten</button>
</div>
<p id="probe-status" class="muted"></p>
<table class="data" id="probe-table" style="display:none">
<thead><tr><th>#</th><th>Prüfung</th><th>Ergebnis</th><th>Details</th></tr></thead>
<tbody></tbody>
</table>
<p class="hint" id="probe-target" style="display:none"></p>
</div>
{% endblock %}