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:
co-authored by
Claude Opus 5
parent
8aa22e6591
commit
ae69c5ce31
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user