Files
pyramid/docs/DASHBOARD_AUTH_HARDENING.md
T

9.2 KiB
Raw Blame History

Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung

Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate („Weg C", siehe unten) geschlossen, und Bernd hat entschieden (ANTWORTEN_BERND.md Nr. 3): KEIN Fernzugriff das Gate ist der ENDZUSTAND. 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; unterwegs kommt Bernd per WireGuard ins Heimnetz. Die A/B-Frage (Cloudflare Access vs. DASH_TOKEN) ist damit GESCHLOSSEN die Optionen unten bleiben nur als Archiv stehen, falls sich der Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst akzeptiert. Nicht weiter an diesem Thema arbeiten.

Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)

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 Gates (Bernd bekannt und bewusst akzeptiert, Entscheidung 2026-07-05):

  • 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 <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):

  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 <DASH_TOKEN> + 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 nicht die 403-Meldung im Dashboard erklärt das und nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only, unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau (falls je nötig) = die Gate-Zeilen in server.py entfernen (Backup: server.py.bak-20260704-dashgate).