# 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:8080` schicken (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-registration` und `/api/create-invite` – **offene Registrierung am Homeserver aktivieren** (setzt `allow_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` – eigener `DIAG_TOKEN` (Bearer). - `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen 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: 1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für `dashboard.steggi-matrix.work` anlegen. 2. Policy: nur Bernds E-Mail (`bernd.steckmeister@gmail.com`) per One-Time-PIN oder Google-Login zulassen. 3. Fertig – `server.py` und `stats.html` bleiben **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 ` verlangen: ```python # server.py, zu den anderen Token-Konstanten: DASH_TOKEN = "" 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: ```python 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: ```javascript 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):** 1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen, `Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle). 2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet. 3. Mit `Authorization: Bearer ` + leerem Nutzer → 400 (App-Validierung erreicht) → beweist: Token akzeptiert, Auth greift. 4. 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`).