server.py (auf dem Pi, nicht im Repo): /api/ban, /api/unban, /api/toggle-registration, /api/create-invite, /api/run-stats nur noch fuer Heimnetz-/localhost-Socket-IPs ohne Cloudflare-Header; sonst 403 mit Erklaerung. Headless verifiziert (9 Pruefungen). Bernds A/B-Entscheidung fuer den Fernzugriff bleibt offen (docs/DASHBOARD_AUTH_HARDENING.md). Zusatzbefund: Repo privat schalten wuerde In-App-Updater brechen (ROADMAP). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.9 KiB
Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
Status (aktualisiert 2026-07-04): Das akute Loch ist mit einem INTERIMS-GATE
geschlossen („Weg C", siehe unten) – die fünf Admin-Endpoints akzeptieren nur
noch Anfragen aus dem Heimnetz/localhost; alles, was über den Cloudflare-Tunnel
kommt, bekommt 403 mit einer Erklärung (die das Dashboard per alert() anzeigt).
Bernds Dashboard funktioniert im Heimnetz unverändert unter
http://192.168.178.71:8080/stats.html; die öffentliche URL zeigt weiterhin die
Ansicht, nur die Admin-Buttons sind dort gesperrt. Offen bleibt Bernds
Entscheidung Weg A (Cloudflare Access, empfohlen) oder Weg B (DASH_TOKEN), falls
er die Admin-Buttons auch von unterwegs braucht – das Gate ersetzt die
Entscheidung nicht, es stopft nur das Loch, solange sie aussteht.
Interims-Gate („Weg C", aktiv seit 2026-07-04)
In server.py (Konstante ADMIN_PATHS + Helfer lan_denied_reason): eine
Admin-POST-Anfrage wird nur angenommen, wenn (1) kein Cf-Connecting-Ip/
Cf-Ray-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
(2) die Socket-Absender-IP (nicht spoofbar) 127.0.0.1/::1/192.168.*
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
security_alerts.log und damit in der Dashboard-Anzeige. Headless verifiziert:
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
GET / öffentlich → 200, /api/livekit-token (401 bei ungültigem Token) und
/api/e2ee-diagnostics (403 bei falschem Token) unverändert öffentlich
erreichbar (die App braucht sie, eigene Auth vorhanden).
Bekannte Restrisiken des Interims-Gates (Gründe, warum Weg A trotzdem kommen sollte):
- Die öffentliche Ansicht bleibt offen und zeigt Nutzerliste + Präsenz (Informations-Preisgabe). Weg A würde auch das schließen.
- CSRF aus dem Heimnetz: Eine bösartige Webseite in Bernds Browser könnte
theoretisch POSTs an
http://192.168.178.71:8080schicken (moderne Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig). Erst ein Token/Access-Login (Weg A/B) schließt das sauber. - Wer im Heimnetz ist, kann weiterhin ohne Anmeldung administrieren.
Problem (belegt, nicht vermutet)
Der Dashboard-Server /home/steggi/matrix/server.py (läuft als
matrix-stats.service auf 0.0.0.0:8080) ist über Cloudflare unter
https://dashboard.steggi-matrix.work öffentlich aus dem Internet erreichbar.
Getestet: GET / liefert 200 (stats.html), und die zustandsändernden POST-Endpoints
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung:
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
Damit kann jeder Fremde im Internet, der den Hostnamen kennt, ohne Anmeldung:
/api/ban//api/unban– jeden Matrix-Nutzer sperren/entsperren (auch Uta!) → Aussperrung / Denial-of-Service gegen echte Nutzer./api/toggle-registrationund/api/create-invite– offene Registrierung am Homeserver aktivieren (setztallow_registration = true+yes_i_am_very_very_sure…) → der Homeserver wird zur Spam-/Missbrauchsschleuder./api/run-stats– Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
Der Hostname ist nicht wirklich geheim: Cloudflare stellt TLS-Zertifikate aus,
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
steggi-matrix.work ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
Bereits korrekt abgesichert (zum Vergleich, nicht betroffen):
/api/e2ee-diagnostics– eigenerDIAG_TOKEN(Bearer)./api/livekit-token– Matrix-Access-Token perwhoamigegen Continuwuity.
Nur die vier Admin-/Stats-Endpoints oben sind offen.
Wie das Dashboard heute aufruft
stats.html ruft alle Aktionen als Same-Origin-fetch ohne Auth-Header auf
(toggleRegistration, createInvite, refreshStats, doUserAction →
/api/ban //api/unban). Es gibt keine Session, kein Cookie, keinen Token –
deshalb bricht jede hinzugefügte Authentifizierung das Dashboard so lange,
bis stats.html den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
und kein stiller Fix.
Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
Am saubersten wird das Dashboard komplett durch Cloudflare Access geschützt
(Zero-Trust-Login vor der Subdomain), statt in server.py Auth nachzubauen:
- Im Cloudflare-Zero-Trust-Dashboard eine Access-Application für
dashboard.steggi-matrix.workanlegen. - Policy: nur Bernds E-Mail (
bernd.steckmeister@gmail.com) per One-Time-PIN oder Google-Login zulassen. - Fertig –
server.pyundstats.htmlbleiben unverändert, das Dashboard funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen gar nicht erst an die Endpoints.
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). Das ist der Königsweg – Option B nur, falls Access nicht gewünscht ist.
Option B (Fallback, rein in server.py): eigener Dashboard-Token
Wenn Access nicht gewünscht ist, ein neuer, zufälliger DASH_TOKEN (getrennt
vom geleakten Admin-Token J5lax… und vom DIAG_TOKEN!), den die vier
Admin-/Stats-Endpoints als Authorization: Bearer <DASH_TOKEN> verlangen:
# server.py, zu den anderen Token-Konstanten:
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
def _dash_authed(self):
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
In do_POST vor der Behandlung von /api/ban, /api/unban,
/api/toggle-registration, /api/create-invite, /api/run-stats einfügen:
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
self.send_error(403)
return
GET / und /api/registration-status bleiben öffentlich (nur Lesen), damit die
Stats-Seite ohne Token sichtbar bleibt.
stats.html darf den Token NIEMALS fest eingebettet ausliefern (die Seite
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
Browser abfragen und in localStorage halten:
function dashToken() {
var t = localStorage.getItem('dashToken');
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
return t || '';
}
// und bei JEDEM Aktions-fetch:
fetch('/api/ban', { method:'POST',
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
body: JSON.stringify({user:u}) })
(betrifft toggleRegistration, createInvite, refreshStats, doUserAction).
Bei 403 den gecachten Token verwerfen (localStorage.removeItem('dashToken')) und
neu fragen. Bernd gibt den Token so genau einmal pro Browser ein.
Rollout / Testplan
Option A: Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
https://dashboard.steggi-matrix.work/ zum Access-Login umleitet statt die Seite
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
Option B (headless auf dem Pi verifizierbar):
server.py+stats.htmländern, Dienst-Neustart (Prozess killen,Restart=alwayslädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).curl -X POST .../api/ban -d '{"user":"x"}'ohne Bearer → 403 erwartet.- Mit
Authorization: Bearer <DASH_TOKEN>+ leerem Nutzer → 400 (App-Validierung erreicht) → beweist: Token akzeptiert, Auth greift. - Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen (Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an einem Test-Nutzer, nicht an Uta).
Warum erst nur der Plan kam – und dann doch das Interims-Gate
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
CLAUDE.md-Leitplanke „kein Aussperren". Das Wie der Anmeldung bleibt deshalb
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
die öffentliche URL gehen bis zur A/B-Entscheidung nicht – die 403-Meldung im
Dashboard erklärt das und nennt die Heimnetz-URL. Rückbau = die Gate-Zeilen in
server.py entfernen (Backup: server.py.bak-20260704-dashgate).