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 ([email protected]) 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).