From e09108ec352d8357c0c3d481042eec018b35cec2 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 23 Apr 2026 09:38:56 +0200 Subject: [PATCH] Update projektplan with CSRF and security headers --- docs/projektplan.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/projektplan.md b/docs/projektplan.md index 5ebd410..cb042f1 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -696,9 +696,11 @@ Fall 09:32 — 3 Aufnahmen **Phase-4-Entwurf (noch nicht gebaut):** - Nachtrag-Hinweis-Banner („⚠ 1 neue Aufnahme seit Abschluss — neu analysieren?"): funktional durch den Auto-Trigger abgedeckt (Doku oben), als sichtbarer Banner aber noch offen. - Preset-System + Undo für Dokument-Versionen (Arztbrief / Kürzer / Formeller / Diagnosen / Medikamente). -- CSRF-Tokens in allen POST-Formularen. - Service-Ausfall-Warnung im UI (> 30 Min. Ausfall). +**Phase-4-Entwurf (inzwischen umgesetzt):** +- CSRF-Tokens in allen state-changing POST-Formularen — implementiert 2026-04-23 (`CsrfForm`-Extractor + `partials/csrf_field.html`-Macro). Details siehe Abschnitt „6. Sicherheit". + --- ### 3. Filesystem-Struktur @@ -896,8 +898,8 @@ Ziel: neue Presets ohne Code-Änderung hinzufügbar. | Datenhaltung | /data/ nur root lesbar | | IDOR-Prävention | Arzt-Identität kommt ausschließlich aus der Session, nie aus der URL. `AuthenticatedArzt`-Extractor leitet den Dateisystempfad serverseitig ab. | | Input-Validierung | `case_id` wird als UUIDv4 validiert (`uuid::Uuid::parse_str`, aktuell inline in Handlern; `ValidCaseId`-Extractor als Phase-4-TODO). Ungültige Werte → 400. Verhindert Path-Traversal. | -| CSRF | Token pro Session, Hidden Field in allen POST-Formularen, serverseitige Validierung. `SameSite=Strict` als zusätzliche Ebene. | -| Security Headers (nginx) | `Content-Security-Policy: default-src 'self'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` | +| CSRF | **Implementiert (2026-04-23):** `CsrfForm`-Extractor mit constant-time-compare (`subtle`), pro-Session-Token (Login + Magic-Link minten, nicht rotiert), Askama-Macro rendert Hidden Field in allen state-changing `/web/`-POST-Formularen. `SameSite=Strict`-Cookie bleibt als zweite Ebene. Login und `/api/...` sind exempt. | +| Security Headers | **Zweischichtig:** Axum-Defense-in-Depth via `tower-http::SetResponseHeaderLayer` (in `create_router_with_state`, `if_not_present`-Mode erlaubt Per-Route-Overrides) — aktiv auch ohne nginx. Zusätzlich nginx-Layer bei TLS-Terminierung (geplant). Gesetzt: `Content-Security-Policy` (enthält `'unsafe-inline'`, weil Templates Inline-JS haben — revisiten, sobald User-Input unescaped gerendert wird), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`. **HSTS bewusst ausgelassen**, bis TLS-Terminierung steht (gecachter `max-age` auf Plain-HTTP-Deployment ist irreversibel). | --- @@ -1016,8 +1018,10 @@ doctate/ **`doctate-common`** ist die Quelle der API-Wahrheit zwischen Server und Clients: - `ack::AckResponse`, `ack::AckStatus` — ACK-Typen für `POST /api/upload` -- `oneliners::OnelinersResponse`, `OnelinerEntry` — Wire-Format für `GET /api/oneliners` +- `oneliners::OnelinersResponse`, `OnelinerEntry`, `ONELINER_FILENAME` — Wire-Format + kanonischer Dateiname für `GET /api/oneliners` - `timestamp::now_rfc3339()`, `filename_stem_to_recorded_at()`, `recorded_at_to_filename_stem()` — bijektive Umwandlung UTC-RFC3339 ↔ Filesystem-safe-Name (Colon ↔ Hyphen im Zeit-Teil) +- `bulk::BulkAction` — getyptes Enum für `close`/`analyze`/`reset` im Bulk-Handler, `FromStr` mit stabiler Fehlermeldung. Ersetzt die String-Literale, die vorher zwischen Handler und drei Testdateien dupliziert waren; Handler-Match ist exhaustive. +- `url::join_url` — Helper zum sauberen Zusammensetzen von `base_url` + `path` (trimmt Trailing-Slash + vorangestellte Slashes einheitlich); ersetzt sieben identische `trim_end_matches`+`format!`-Stellen in `client-core`, `client-desktop`, `server/transcribe::{ollama,whisper}` und `server/analyze::llm`. - `constants::API_KEY_HEADER` (= `"X-API-Key"`), `UPLOAD_PATH`, `FIELD_CASE_ID`, `FIELD_RECORDED_AT`, `FIELD_AUDIO`, `CONTENT_TYPE_AUDIO_MP4` Deps von `doctate-common` sind minimal: nur `serde`, `uuid`, `time` — **kein** `tokio`, `reqwest`, `axum`. Dadurch bleibt sie runtime-agnostisch und kompiliert auch in WASM-Kontexten, falls je ein Browser-Client dazukommt. @@ -1092,9 +1096,9 @@ wiremock = "0.6" - [x] Docker Container für Axum auf Ubuntu Server (kein GPU nötig) - [x] faster-whisper Container mit HTTP-API (NVIDIA Container Toolkit) — jetzt eigener `whisper/`-Service, siehe Phase 2b.5 - [x] Ollama einrichten, Modell gepullt (`gemma4:latest` statt `gemma3:4b`, siehe Abweichungen) -- [ ] nginx auf Unraid + Let's Encrypt + Security Headers (CSP, X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, Strict-Transport-Security/HSTS) +- [ ] nginx auf Unraid + Let's Encrypt + TLS-Terminierung. **HSTS (`Strict-Transport-Security`) zieht nginx** (nicht Axum), sobald TLS stabil läuft — vorher irreversibel. Die übrigen Security-Header (`CSP`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`) kommen schon aus dem Axum-Layer (siehe nächster Punkt); nginx darf sie duplizieren, muss aber nicht. - [ ] HTTP-Compression (gzip/brotli): Policy-Entscheidung nginx vs. `tower-http::CompressionLayer` (Axum) — bei direkter Axum-Exposition im Dev-Setup muss die Compression dort laufen, hinter nginx ist `gzip on` meist einfacher. Vorsicht bei SSE: `text/event-stream` darf **nicht** komprimiert werden (buffering bricht Live-Updates) -- [ ] Axum-seitige Security-Header als Defense-in-Depth (tower-http `SetResponseHeaderLayer`): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy`, `Referrer-Policy: no-referrer`, `Strict-Transport-Security` (nur wenn HTTPS garantiert). Soll greifen, auch wenn nginx wegfällt oder im Dev-Mode direkt auf Axum zugegriffen wird. Magic-Link-Route setzt `Referrer-Policy: no-referrer` bereits — Layer zentralisiert das +- [x] Axum-seitige Security-Header als Defense-in-Depth (`tower-http::SetResponseHeaderLayer` in `create_router_with_state`, `if_not_present`-Modus — nicht in `main.rs`, damit Integrationstests dieselbe Response-Shape wie Prod sehen): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy` (enthält `'unsafe-inline'` für Script/Style, weil Templates Inline-JS nutzen), `Referrer-Policy: no-referrer`, `Permissions-Policy`. HSTS bewusst **weggelassen**, bis TLS-Terminierung steht — ein gecachter `max-age` auf Plain-HTTP-Deployment ist irreversibel. `if_not_present` respektiert Per-Route-Overrides (z.B. Magic-Link-Handler setzt eine eigene `Referrer-Policy`). ### Phase 2 — Transkriptions-Pipeline - [x] Transkriptions-Queue (tokio mpsc channel) @@ -1161,7 +1165,7 @@ wiremock = "0.6" - [ ] Preset-basiertes „Neu generieren" (Preset + Freitext, Freitext max. 500 Zeichen) — hängt an Dokument-Versionierung - [ ] Undo-Button für Dokument-Versionen — hängt an Versionierung - [ ] Service-Ausfall-Warnung (faster-whisper/Ollama/LLM > 30 Min.) -- [ ] CSRF-Token pro Session (Hidden Field in allen POST-Formularen, serverseitige Validierung) +- [x] CSRF-Token pro Session: `CsrfForm`-Extractor (`server/src/csrf.rs`) als Drop-in-Ersatz für `Form` auf jeder state-changing `/web/`-POST-Route (bulk, purge-closed, reset, close, reopen, analyze, delete-recording, logout). Constant-Time-Compare via `subtle::ConstantTimeEq` (Timing-Oracle-Schutz). Token wird bei Login **und** Magic-Link-Konsum einmalig gemintet und bleibt für die Session stabil — **keine Per-Request-Rotation** (bricht Multi-Tab-Nutzung, `SameSite=Strict` deckt das übrige Angriffsprofil). Templates binden via `partials/csrf_field.html`-Macro (Hidden Input); `AuthenticatedWebUser` trägt das Token, damit Render-Handler keinen zweiten Store-Lookup brauchen. Login-Route bleibt unbeschützt (Forced-Login-CSRF hat auf diese Codebasis keinen Impact); `/api/...` ist header-auth und exempt. Fehlerpfad: fehlende/abgelaufene Session → `/web/login`-Redirect, malformed body → 400, Token-Mismatch → 403. **Admin-Features (integriert in die Arzt-UI)** - [x] `hash-password` CLI (`cargo run --bin hash-password`) — erzeugt bcrypt-Hashes für `users.toml`, mit `toml_edit`-Schreibzugriff ohne Kommentarverlust. @@ -1256,7 +1260,7 @@ wiremock = "0.6" - [ ] Mehrere Ärzte testen - [ ] IDOR-Test: eingeloggter Arzt A versucht `case_id` von Arzt B aufzurufen → 404, kein Datenleck - [ ] Session-Ablauf testen: nach 8 h → Redirect zu Login, SSE-Stream geschlossen -- [ ] CSRF-Test: POST ohne gültiges CSRF-Token → 403 +- [x] CSRF-Test: POST ohne gültiges CSRF-Token → 403 (`server/tests/csrf_attack_test.rs`). Zusätzlich: Safety-Net-Test „jede gerenderte Seite enthält das aktive Session-Token als Hidden Input" (fängt Formulare, die das Macro vergessen), und Happy-Path-Roundtrip (Seite abrufen → Token aus HTML parsen → POST → 303) fängt Drift zwischen gerendertem und akzeptiertem Token-Format. Security-Header-Layer hat eigene Regressionstests in `server/tests/security_headers_test.rs` (10 Attack-Confirming + 2 Anchor-Tests). - [ ] Path-Traversal-Test: `case_id` mit `../` → 400 - [ ] Ungültige `case_id` (kein UUID) → 400 - [ ] Lazy Cleanup testen (Watch: nur Marker-Dateien von gestern gelöscht, Audiodateien in unsynced/ bleiben) @@ -1358,6 +1362,8 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche | Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. | | Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. | | Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. | +| Test-Infrastruktur | Jede Integrationstest-Datei brachte eigenen Harness mit (User-Factories, TestConfig-Builder, Login-Flow inline) | **Shared-Modul `server/tests/common/`** (2026-04-22/23): `config`, `http`, `paths`, `seed`, `session`, `users`, `artefacts`. 95 Tests migriert, ~780 Zeilen Duplikat entfernt, neue Tests starten mit drei Zeilen statt achtzig. | Beim Einführen von CSRF mussten alle POST-Tests durch `common::session` den Login→CSRF→POST-Flow abwickeln — ad-hoc-Harnesses hätten jede Testdatei gleichzeitig umgebaut. Zusätzlicher Gewinn: `health_test` nutzte vorher einen hartcodierten `/tmp/doctate-test`-Pfad, der bei parallelen `cargo test`-Läufen kollidierte; das einheitliche Unique-Tmpdir-Pattern hat diese Flake entfernt. | +| Konsolidierung wiederholter Muster | Bulk-Action-Literals, URL-Assembly, Case-Artefakt-Dateinamen als inline-Strings an 10+ Stellen | Drei Helper-Gruppen in `doctate-common` + `server/tests/common/artefacts.rs` (siehe Cargo-Workspace-Layout): `BulkAction`-Enum, `join_url`, und zentral re-exportierte Artefakt-Filenames (`ONELINER_FILENAME`, `DOCUMENT_FILE`, `ANALYSIS_INPUT_FILE`, `CLOSE_MARKER`). Zusätzlich in `client-desktop`: `paths::project_path`-Helper, `main::or_die`-Helper, `RecordingContext`-Struct statt `(Uuid, String)`-Tupel. | Rename oder Wire-Format-Tweak bearbeitet jetzt eine Datei statt 10+. `BulkAction`-Match ist exhaustive — neue Actions fallen beim Kompilieren auf. | ### Authentifizierung @@ -1366,6 +1372,13 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche | Browser-Login | Nur Passwort-Formular gegen `users.toml` | Zusätzlich Magic-Link: `POST /api/auth/magic-link` (API-Key) → `GET /web/magic?token=…` (60 s TTL, one-time-use, `return_to` auf `/web/`-Pfade whitelisted, `Referrer-Policy: no-referrer`) | Der Desktop-Client hat den API-Key ohnehin, das Passwort separat einzutippen ist Friktion ohne Sicherheitsgewinn. Flow nutzt den vorhandenen `AuthenticatedUser`-Extractor, sodass Policy an einer Stelle bleibt. | | Admin-Gating für destruktive Aktionen | `Reset`/`Bulk` für jeden eingeloggten Arzt | `AuthenticatedUser::is_admin()` (`role == "admin"`) — Handler für `POST /web/cases/{id}/reset`, `POST /web/cases/bulk` und `POST /web/cases/purge-closed` rejecten Nicht-Admins. Der Admin-Check sitzt am *Entry-Handler* (`bulk.rs::handle_bulk`, nicht pro Sub-Action wie `bulk_analyze`/`bulk_delete`), damit eine neu hinzugefügte Bulk-Operation nicht versehentlich ungeschützt bleibt. Templates blenden Buttons und die Purge-Form via `is_admin: bool`-Feld aus (Defense-in-Depth). | Reset wirft alle Transkripte weg, Bulk kann viele Fälle löschen, Purge entfernt geschlossene Fälle hart. Admin-Gate schützt vor Fat-Finger während der Entwicklung und bleibt auf Dauer sinnvoll. Authorization am Rand (Entry-Handler statt Sub-Action) ist robuster gegen Code-Drift. | +### Sicherheit / Hardening + +| Änderung | Original | Aktuell | Grund | +|---|---|---|---| +| CSRF-Schutz | Als Phase-4-TODO skizziert („Token pro Session, Hidden Field, serverseitige Validierung") | **Implementiert (2026-04-23):** `CsrfForm`-Extractor mit `subtle::ConstantTimeEq`, Token bei Login + Magic-Link-Konsum gemintet und **nicht rotiert**, Askama-Macro (`partials/csrf_field.html`) für alle state-changing Forms. Login bleibt unbeschützt, `/api/...` ist exempt. | Per-Request-Rotation würde Multi-Tab-Nutzung brechen und bringt gegenüber `SameSite=Strict` keinen realen Zusatzschutz. Constant-Time-Compare schließt Timing-Oracles am Token aus. Login-Exempt: Forced-Login-CSRF hat auf diese Codebasis keinen Impact (kein Konto-Merge, keine verknüpften Aktionen). `/api/...`-Exempt: Header-Auth (`X-API-Key`) ist nicht cookie-gebunden → kein CSRF-Vektor. | +| Security-Header | Im Plan als nginx-Aufgabe ausgewiesen (Phase 1) | **Zweischichtig:** Axum-Layer (`tower-http::SetResponseHeaderLayer` in `create_router_with_state`, `if_not_present`-Mode) + geplanter nginx-Layer bei TLS-Terminierung. HSTS bewusst ausgelassen. CSP enthält `'unsafe-inline'` für Inline-JS/CSS in Templates. | Der Axum-Layer schützt auch dann, wenn nginx wegfällt (Dev-Direkt-Zugriff, Proxy-Misconfig) oder wenn Integrationstests die Response-Shape prüfen — der Layer sitzt deshalb in `create_router_with_state`, nicht in `main.rs`. `if_not_present` erlaubt Per-Route-Overrides (z.B. Magic-Link setzt eigene `Referrer-Policy`). HSTS auf Plain-HTTP-Deployment ist irreversibel (gecachter `max-age` sperrt den Browser aus, sobald TLS mal ausfällt) — erst mit stabiler TLS-Terminierung aktivieren. `'unsafe-inline'` ist eine bewusste Schuldenposition: revisiten, sobald User-Input unescaped gerendert wird. | + ### Client-Architektur | Änderung | Original | Aktuell | Grund |