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)
|
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
@@ -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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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