Update projektplan with CSRF and security headers
This commit is contained in:
+21
-8
@@ -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<T>`-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<T>`-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<T>`-Extractor (`server/src/csrf.rs`) als Drop-in-Ersatz für `Form<T>` 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<T>`-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 |
|
||||
|
||||
Reference in New Issue
Block a user