# 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:

```python
# 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:

```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 <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`).
