Files
mission-control-v2/backend/services/geheimnisse.py
T
HitonabiandClaude Opus 5 ae69c5ce31 fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet
Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.

## B-01 — Box-Sudo-Passwort: ersatzlos entfernt

Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.

AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:

  frontend/src/lib/api.ts             kein localStorage-Zugriff mehr
  frontend/.../SettingsTab.tsx        Eingabefeld weg, dafuer die Erklaerung warum
  backend/routers/maintenance.py      SudoReq entfaellt, 8 Endpunkte entschlackt
  backend/services/maintenance.py     _run() nutzt immer `sudo -n`
  backend/services/jobengine.py       keine stdin-Pipe mehr (DEVNULL)

`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.

## HuggingFace-Token: liegt jetzt auf der Box

Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.

## B-14 — Fehlermeldungen sagen jetzt, was los ist

api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).

## B-07 — Tests und Linter, ehrlich eingeordnet

Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.

  Vitest + Testing Library, 17 Tests in 3 Dateien
  ESLint (flat config) + Prettier
  Beides jetzt Teil der Ampel

Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).

ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.

## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy

Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).

## Nachgemessen

  Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
  tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler

Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:44:48 +02:00

101 lines
4.0 KiB
Python

"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
gleichbedeutend mit Root auf der Box gewesen.
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
erreicht — nicht, dass die Datei unlesbar wäre.
"""
import json
import logging
import os
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
ERLAUBT = frozenset({"hf_token"})
def _lesen() -> dict[str, str]:
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
try:
daten = json.loads(PFAD.read_text(encoding="utf-8"))
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
except (OSError, ValueError):
return {}
def _schreiben(daten: dict[str, str]) -> bool:
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
tmp = PFAD.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
tmp.replace(PFAD)
return True
except OSError as exc:
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
return False
def hf_token() -> str | None:
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
def setzen(schluessel: str, wert: str | None) -> bool:
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
if schluessel not in ERLAUBT:
return False
daten = _lesen()
if wert:
daten[schluessel] = wert
else:
daten.pop(schluessel, None)
return _schreiben(daten)
def status() -> dict[str, bool]:
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
daten = _lesen()
return {
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
"schreibbar": _schreibbar(),
}
def _schreibbar() -> bool:
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
return os.access(PFAD.parent, os.W_OK)
except OSError:
return False