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>
This commit is contained in:
Hitonabi
2026-08-28 08:44:48 +02:00
co-authored by Claude Opus 5
parent 8aa22e6591
commit ae69c5ce31
47 changed files with 9347 additions and 6240 deletions
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { ApiError, api } from "./api"
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
try {
await api(pfad, init)
} catch (e) {
if (e instanceof ApiError) return e
throw e
}
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
}
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
function antwort(status: number, rumpf: string, ok = status < 400): Response {
return {
ok,
status,
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
text: async () => rumpf,
json: async () => JSON.parse(rumpf),
} as unknown as Response
}
function fetchAttrappe(res: Response) {
const spion = vi.fn(async () => res)
vi.stubGlobal("fetch", spion)
return spion
}
afterEach(() => vi.unstubAllGlobals())
describe("api()", () => {
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
})
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
await expect(api("/api/maintenance/restart", { method: "POST" }))
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
})
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
await expect(api("/api/models/install", { method: "POST" }))
.rejects.toThrowError("repo fehlt; quant ungueltig")
})
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
fetchAttrappe(antwort(500, ""))
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
})
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(502)
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
expect(typeof fehler.message).toBe("string")
expect(fehler.message.length).toBeGreaterThan(0)
})
it("traegt status und detail am Fehlerobjekt", async () => {
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
const fehler = await fehlerVon("/api/test")
expect(fehler.status).toBe(400)
expect(fehler.detail).toBe("kaputt")
})
})
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
// Bequemlichkeit je wieder einbaut, macht sie rot.
describe("api() traegt keine Geheimnisse", () => {
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
localStorage.setItem("mc_hf_token", "hf_geheim")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: "llama-swap" }),
})
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
const kopfzeilen = JSON.stringify(init.headers ?? {})
const rumpf = String(init.body ?? "")
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
expect(kopfzeilen).not.toContain("geheim123")
expect(rumpf).not.toContain("geheim123")
expect(rumpf).not.toContain("hf_geheim")
expect(rumpf).not.toContain("sudo_password")
expect(rumpf).not.toContain("hf_token")
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
})
it("erfindet auch ohne Rumpf keinen", async () => {
localStorage.setItem("mc_sudo_password", "geheim123")
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
await api("/api/maintenance/reboot", { method: "POST" })
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
expect(init.body ?? null).toBeNull()
})
})
+647 -646
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel ueberleben). Fuer jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein.
async function frischerStore() {
vi.resetModules()
return await import("./metricsStore")
}
describe("metricsStore", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
})
it("beginnt leer", () => {
const { result } = renderHook(() => store.useSysHistory())
expect(result.current).toEqual([])
})
it("meldet neue Messpunkte an die Abonnenten", () => {
const { result } = renderHook(() => store.useSysHistory())
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
expect(result.current).toHaveLength(1)
expect(result.current[0].cpu).toBe(10)
})
// Ohne Deckel waechst der Verlauf unbegrenzt — bei 3-s-Takt sind das ~28 800 Punkte
// pro Tag in einem Dauerlaeufer-Tab.
it("deckelt den Verlauf und behaelt die JUENGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory())
act(() => {
for (let i = 0; i < 60; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
})
expect(result.current).toHaveLength(40)
expect(result.current[0].t).toBe(20)
expect(result.current[39].t).toBe(59)
})
it("haelt System- und Token-Verlauf auseinander", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
expect(sys.current).toHaveLength(0)
expect(tok.current).toHaveLength(1)
expect(tok.current[0].completion).toBe(7)
})
// useSyncExternalStore vergleicht per Referenz: wuerde pushSys das Array an Ort und
// Stelle veraendern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Aenderung ein NEUES Array (Referenzwechsel)", () => {
const { result } = renderHook(() => store.useSysHistory())
const vorher = result.current
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
expect(result.current).not.toBe(vorher)
})
})
+31
View File
@@ -0,0 +1,31 @@
// Umgang mit einem veralteten Bündel (v3-Umbau P1).
//
// DAS PROBLEM, hier gemessen am 28.08.2026: Ein Deploy ersetzt `frontend/dist` und startet
// den Dienst neu. Offene Browser-Tabs behalten aber ihr altes, bereits geladenes
// Start-Bündel — und dessen Nachlade-Chunks (`IdeenView-J4hS7xB2.js` …) tragen den Hash
// des ALTEN Builds. Die Dateien sind weg. Beim nächsten Ansichtswechsel wirft der
// dynamische Import, die Fehlergrenze schlägt zu und der Nutzer sieht eine kaputte Seite,
// obwohl nur sein Tab alt ist.
//
// Vite meldet genau diesen Fall als `vite:preloadError`. Wir laden dann EINMAL neu — der
// Browser holt `index.html` (das nie gecacht wird, siehe backend/app.py) und damit das
// aktuelle Bündel. Die Sperre in sessionStorage verhindert eine Schleife, falls der Chunk
// aus einem anderen Grund fehlt (Netz weg, Datei wirklich kaputt): Dann bleibt es beim
// ehrlichen Fehler statt bei einer endlos neuladenden Seite.
const SPERRE = "mc_neuladen_wegen_version"
export function veralteteVersionAbfangen() {
window.addEventListener("vite:preloadError", (e) => {
if (sessionStorage.getItem(SPERRE)) return // schon versucht — Fehler durchlassen
e.preventDefault() // verhindert, dass der Import zusätzlich als Absturz hochblubbert
sessionStorage.setItem(SPERRE, "1")
window.location.reload()
})
// Hat der Neustart geklappt, ist die Sperre nach dem nächsten erfolgreichen Start wieder
// frei — sonst wäre der Schutz nach dem ersten Deploy für diese Sitzung verbraucht.
window.addEventListener("load", () => {
window.setTimeout(() => sessionStorage.removeItem(SPERRE), 5_000)
})
}