66bf54bdff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
178 lines
9.2 KiB
Markdown
178 lines
9.2 KiB
Markdown
# 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`).
|