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:
+85
@@ -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"]
|
||||
|
||||
+26
@@ -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)}
|
||||
|
||||
@@ -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 ================= */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.__PLAN_DATA__) initPlan();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<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="/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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user