dorfteich/docs/vs-nfd/60-sicherheitsdokumentation.md
Claude Fable 5 4c7f001cab
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#215: trusted reverse-proxy header / mTLS client-certificate path
For perimeters that authenticate before the application (ADR 0021 §4).
Off unless BOTH AUTH_PROXY_HEADER and AUTH_PROXY_TRUSTED_PEERS are set —
nothing about the header is guessed. The peer check runs against the TCP
peer address only (a forwarded header is attacker-influenced): a request
carrying the header from any other peer is rejected outright and audited
as auth.proxy_rejected (catalogue v1.4) — that is a spoof attempt, not a
misconfiguration — even when a valid session cookie rides along. From a
trusted peer the header IS the identity; a session cookie never
escalates beyond it; with the feature off the header is inert.

Mapping is explicit (AUTH_PROXY_MAP: username or e-mail); deliberately
no just-in-time creation — the header carries no verified address. The
mTLS variant (AUTH_PROXY_MODE=mtls-dn) maps the configured attribute
(default CN) out of the certificate subject DN the TLS terminator
forwards, under the same peer rules. Session-less proxy requests key the
read trail per user (user:<id>).

The trust boundary is stated in security.md (the section an assessor
reads closest), the VS-NfD security documentation and the hardening
guide's deploy table. Tests cover all four decisions: off = inert,
trusted peer authenticates (username and DN mapping), untrusted peer
rejected + audited, no escalation past a session cookie.

Refs #215.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:50:09 +02:00

328 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Sicherheitsdokumentation (VS-NfD, Issue #228)
Zweck: das Dokument, das ein Prüfer zuerst liest — Architektur,
Datenflüsse, Netzplan, Vertrauensgrenzen und die vollständige Liste
aller Inhaltskopien. Es konsolidiert bestehende Referenzdokumente,
erfindet nichts neu und ist **auf die Ziffer genau** an
`deploy/compose/docker-compose.yml` ausgerichtet (die Compose-Datei ist
die maßgebliche Dienst- und Portliste; weicht dieses Dokument ab, ist
das ein Fehler in diesem Dokument).
Einordnung: Dorfteich erbringt **keine Sicherheitsgrundfunktion**
(§52 VSA, ADR 0019, Abgrenzungserklärung
`40-abgrenzungserklaerung.md`). Diese Dokumentation beschreibt, was die
Anwendung tut — Verschlüsselung, Netztrennung, Datenträgerschutz und
Beweissicherung auf Plattformebene fallen dem Betreiber zu.
Quell-Dokumente (englisch, maßgeblich für Details):
`docs/architecture/security.md`, `deployment.md`, `data-model.md`,
`permissions.md`, `realtime-collaboration.md`,
`plugin-architecture.md`, `operations.md`, `audit-events.md`;
Deploy-Sicht: `deploy/stages.md`, `deploy/compose/docker-compose.yml`.
Pflegeregel: Änderungen an Diensten, Ports, Datenflüssen oder
Speicherorten werden **im selben PR** hier nachgezogen (dieselbe Regel
wie beim Härtungsleitfaden `50-haertungsleitfaden.md`).
---
## 1 Komponenten
```mermaid
flowchart LR
subgraph client [Endgerät]
B[Browser SPA]
end
subgraph host [Betreiber-Host / Reverse-Proxy-Zone]
RP[Reverse Proxy TLS]
end
subgraph frontend [Docker-Netz frontend]
W[web nginx statisch]
A[api NestJS]
C[collab Hocuspocus]
end
subgraph internal [Docker-Netz internal]
DB[(PostgreSQL 17.5)]
P[pandoc 3.6]
G[gotenberg 8]
BK[backup Sidecar]
end
SMTP[(SMTP-Relay extern)]
MIR[(Backup-Spiegel rsync/WebDAV, Allowlist)]
B -->|HTTPS 443| RP
RP -->|/| W
RP -->|/api| A
RP -->|/collab WebSocket| C
A --> DB
C --> DB
A -->|HTTP pandoc:3030| P
A -->|HTTP gotenberg:3000| G
A -->|Mail| SMTP
BK --> DB
BK -.->|nur bei Konfiguration + Allowlist| MIR
BK -->|Fehler-Mail| SMTP
```
| Dienst | Image (gepinnt, #203/#236) | Zweck | Privilegien |
| ---------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `web` | eigenes Image (nginx-unprivileged 1.27, uid 101) | statische SPA-Auslieferung | kein Volume, nur Netz `frontend`, kein DB-Zugang |
| `api` | eigenes Image (Node 22.15.1-alpine, User `node`) | REST-API, Permissions, Scheduler-Jobs, Import/Export-Orchestrierung, Audit | Volumes `uploads`, `plugins`, `secrets`, `backups` (ro); Netze `frontend`+`internal`; DB-Vollzugriff |
| `collab` | eigenes Image (Node, User `node`) | Echtzeit-Editing (Yjs/Hocuspocus), Persistenz der Dokumente | Netze `frontend`+`internal`; DB-Vollzugriff; keine Datei-Volumes |
| `db` | `postgres:17.5-alpine@sha256:…` | einziger Datenbestand (außer Uploads) | nur Netz `internal`; Volume `db-data` |
| `pandoc` | `pandoc/core:3.6@sha256:…` | Dokumentkonvertierung (Import/Export DOCX/ODT) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
| `gotenberg` | `gotenberg/gotenberg:8@sha256:…` | HTML→PDF-Rendering (Chromium) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
| `backup` | eigenes Image | nächtlicher `pg_dump -Fc` + Volume-Tar, Retention, optionaler Spiegel | nur `internal`; Volumes `uploads`, `plugins`, `secrets` (ro), `backups` (rw); DB-Zugang |
| `caddy` (Compose-Profil, optional) | `caddy:2.10-alpine@sha256:…` | TLS-Terminierung, wenn kein Host-Proxy existiert | Ports 80/443 nach außen; Netz `frontend` |
Alle eigenen Images laufen als non-root (`USER node`); die Stages auf
ONE nutzen einen Host-Reverse-Proxy (Caddy des Hosts), das
`caddy`-Profil bleibt dort inaktiv.
## 2 Netzplan (Ports und Protokolle)
Maßgeblich: `deploy/compose/docker-compose.yml`. Alle
Anwendungs-Portbindungen sind **auf 127.0.0.1 beschränkt** — von außen
erreichbar ist ausschließlich der Reverse Proxy.
| Von | Nach | Port/Protokoll | Exposition |
| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Client | Reverse Proxy | 443/TCP HTTPS (80 nur Redirect) | **extern** |
| Reverse Proxy | `web` | `127.0.0.1:${WEB_PORT:-8100}` → Container 8080, HTTP | Host-lokal |
| Reverse Proxy | `api` | `127.0.0.1:${API_PORT:-8101}` → Container 3000, HTTP | Host-lokal |
| Reverse Proxy | `collab` | `127.0.0.1:${COLLAB_PORT:-8102}` → Container 3000, HTTP + WebSocket-Upgrade | Host-lokal |
| `api`/`collab`/`backup` | `db` | 5432/TCP (nur Docker-Netz `internal`) | intern |
| `api` | `pandoc` | `http://pandoc:3030` (nur `internal`) | intern |
| `api` | `gotenberg` | `http://gotenberg:3000` (nur `internal`) | intern |
| `api`, `backup` | SMTP-Relay | vom Betreiber konfiguriert (`SMTP_*`) | **ausgehend extern** |
| `backup` | Spiegel-Ziel | rsync über SSH (Port konfigurierbar) bzw. WebDAV/HTTPS — **nur** an Hosts der Deploy-Allowlist `BACKUP_ALLOWED_TARGETS` (ADR 0026, #192); leere Allowlist = alle Fernziele hart aus | **ausgehend extern, allowlist-beschränkt** |
| optional `caddy` | Client | 80/443 | extern (nur wenn Profil aktiv) |
Es gibt **keine eingehenden** Verbindungen außer über den Reverse
Proxy. `db`, `pandoc`, `gotenberg`, `backup` haben keinerlei
Portbindung an den Host.
## 3 Datenflüsse
### 3.1 Authentifizierung
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant DB as PostgreSQL
B->>A: POST /api/v1/auth/login (Origin-Header Pflicht, fail-closed CSRF #189)
A->>DB: user_identities (Argon2id-Hash prüfen), rate_limits (10/min/IP)
A-->>B: Set-Cookie dt_session (HttpOnly, Secure, SameSite=Lax)
Note over A,DB: Session-Row: gehashte Id, expiresAt (SESSION_ABSOLUTE_HOURS),<br/>Idle-Grenze (SESSION_IDLE_HOURS, #190); Audit auth.login_*
```
Externe Authentisierung (OIDC/Proxy-Header) ist geplant (M27,
#214#217) und ändert diesen Fluss; bis dahin ist lokale
Passwort-Authentifizierung der einzige Weg.
### 3.2 Editieren (Echtzeit)
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant C as collab
participant DB as PostgreSQL
B->>A: GET /pages/:id/collab-token (Guard: Read-Recht)
A-->>B: Kurzlebiges Token (60 s TTL, HKDF-Subkey aus COLLAB_TOKEN_SECRET, #188), mode rw/ro
B->>C: WebSocket /collab + Token
C->>C: Token-Verifikation (shared token-crypto), rw nur bei Write-Grant
B-->>C: Yjs-Updates (CRDT)
C->>DB: debounced Persist: pages.ydoc_state + page_updates-Log,<br/>abgeleiteter page_content_cache (plain/markdown/html/outline/tsvector)
A-->>C: Rechteänderungen via Postgres LISTEN/NOTIFY (Kanal-Konstanten shared) → close(4205)
```
### 3.3 Export (PDF/DOCX/ODT/ZIP)
```mermaid
sequenceDiagram
participant B as Browser
participant A as api
participant P as pandoc
participant G as gotenberg
B->>A: POST /pages/:id/export {format}
A->>A: conversion_jobs-Row (input = aufbereitetes Markdown/HTML,<br/>bei eingestufter Seite Option {marking}, #208/#209)
A->>G: html → pdf (Kennzeichnung im Header/Footer-Template je Seite)
A->>P: gfm → docx/odt (reference-doc mit Kennzeichnung, in-Request-Datei)
A-->>B: Poll GET /jobs/:id → Download /jobs/:id/result
Note over A: Payloads werden nach conversion.payloadRetentionDays genullt (#233)
```
Der Pond-ZIP-Export streamt direkt aus `page_content_cache` +
`uploads`-Volume (permissionsgefiltert) und enthält seit M26
Frontmatter/Aufdruck, `manifest.json` und Begleitdateien für
eingestufte Inhalte (#210/#212).
### 3.4 Backup
```mermaid
sequenceDiagram
participant BK as backup-Sidecar
participant DB as PostgreSQL
participant V as Volumes uploads/plugins
participant M as Spiegel (optional)
BK->>DB: nächtlich pg_dump -Fc
BK->>V: tar der Volumes (lesend)
BK->>BK: Restore-Set aufs backups-Volume, Retention (BACKUP_RETENTION_DAYS)
BK-->>M: rsync/WebDAV NUR an BACKUP_ALLOWED_TARGETS (ADR 0026); leer = aus
BK-->>BK: status.json; Fehler-Mail direkt via SMTP
```
Backups sind **unverschlüsselt by design** (ADR 0015/0019) —
Datenträger- und Transportschutz ist Plattformsache
(Abgrenzungserklärung §2).
### 3.5 Lesekanäle
Jeder Kanal, über den Seiteninhalt die Anwendung verlässt (zugleich die
Instrumentierungsliste für den Lesetrail, #222). Der Lesetrail
protokolliert je (Sitzung, Seite, Kanal) **ein Ereignis pro
Dedup-Fenster** (`readTrail.dedupWindowMinutes`, Default 5 Minuten,
#223); jede Zeile trägt die Fensterlänge. Beweiswert damit: „mindestens
ein Zugriff in diesem Fenster" — **nicht** eine Zugriffszählung, und
beim Kanal Collab „Live-Verbindung bestand während des Fensters", nicht
einzelne Sync-Frames. Anonyme Leser teilen sich den Marker `anon`
(keine Fingerprinting-Unterscheidung, Zweckbindung #225):
| Kanal | Pfad | Rechteprüfung | Kennzeichnung (M26) |
| -------------------------- | -------------------------------- | -------------------------------------- | ----------------------------------- |
| SPA-Seitenansicht | `GET /api/v1/pages/:id` u. a. | Guard (shared Resolver) | Banner oben+unten (#206) |
| Public-/No-JS-Ansicht | `GET /api/v1/public/:pond/:page` | `public`-Grant, sonst 404 | Banner oben+unten (#211) |
| Public REST API | `/api/public/v1/**` | `api.enabled` + Pond-Opt-in + PAT | `classification`-Feld (#211) |
| MCP-Endpoint | `/api/mcp` | `mcp.enabled` + Pond-Opt-in + PAT | wie Public API (gleiches Modell) |
| Atom-Feeds | `…/feed.xml` | `feeds.enabled`; Grant bzw. Feed-Token | `<category>`-Element (#211) |
| Suche | `GET /api/v1/search` | per-Treffer-Resolution | Level je Treffer (#211) |
| Attachment-Download | `GET /api/v1/media/:fileId` | Guard über Seite/Pond | Dateinamens-Präfix `VS-NfD_` (#212) |
| Export (PDF/Office/ZIP/MD) | s. 3.3 | Guard; ZIP permissionsgefiltert | je Kanal (#207#210) |
| Collab-WebSocket | `/collab` | Token aus 3.2 | Inhalt = Editor-Ansicht (#206) |
## 4 Vertrauensgrenzen
1. **Client ↔ Instanz** (Internet/Behördennetz): TLS am Reverse Proxy
terminiert (Betreiber-Zone). Die Anwendung setzt die
Security-Header selbst (eigene Middleware, #197: CSP `script-src
'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
der Proxy ist die einzige Eintrittsstelle. Der optionale
Proxy-Header-/mTLS-Authentisierungspfad (#215, Default aus) legt die
Authentisierungs-Vertrauensgrenze an genau diese Stelle: der
konfigurierte Header gilt NUR vom TCP-Peer der Allowlist
(`AUTH_PROXY_TRUSTED_PEERS`); von jedem anderen Peer wird die
Anfrage abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy
MUSS den Header aus eingehendem Verkehr strippen (Betreiberpflicht;
Details: security.md §External authentication).
3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
Zugang dorthin.
4. **Plugin-Sandbox** (ADR 0008): Plugin-Code läuft ausschließlich im
sandboxed iframe (eigene Origin-lose Umgebung, Message-Protokoll,
deklarierte Capabilities); serverseitig wird beim Install ein
Static-Gate erzwungen. Instanzweiter Kill-Switch `plugins.enabled`
(#200); Hash-Pinning ist bewusst verschoben (#232,
Restrisikoliste). Der Sandbox-Escape-Regressionstest (bösartiges
Fixture-Plugin) läuft in CI.
5. **Ausgehende Kanäle**: SMTP (Betreiber-Relay) und Backup-Spiegel
(Allowlist, ADR 0026) sind die einzigen initiierten
Außenverbindungen. Es gibt keine Telemetrie, keine Update-Pings,
keine externen Font-/CDN-Ladungen (ADR 0016: Fonts self-hosted).
6. **Betreiber-Plattform**: Host, Docker-Daemon, Volumes, Netzwerk und
Backups liegen außerhalb der Anwendungsverantwortung
(Abgrenzungserklärung).
## 5 Vollständige Liste der Inhaltskopien
Grundlage des Löschkonzepts im Betriebshandbuch
(`70-betriebshandbuch.md` §5) — Löschen ist nur vollständig, wenn es
jede dieser Kopien erreicht oder ihren Verbleib begründet.
**In der Datenbank (Volume `db-data`):**
| Ort | Inhalt | Lebenszyklus |
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------- |
| `pages.ydoc_state` | aktuelles Dokument (Yjs-Binärzustand) | bis Purge der Seite |
| `page_updates` | inkrementelles Update-Log | Compaction; Purge löscht |
| `page_content_cache` | Klartext, Markdown, HTML, Outline, **Suchvektor** | bei jedem Persist ersetzt; Purge löscht |
| `page_versions` | eigenständige Snapshots (Historie) | bis Purge der Seite |
| `comments` | Kommentartexte | Purge der Seite kaskadiert |
| `conversion_jobs.input/result` | Export-/Import-Payloads (ganze Dokumente) | genullt nach `conversion.payloadRetentionDays` (#233) |
| `mail_outbox` | Mail-Bodies (Digest-Titel!) | gelöscht nach `mail.outboxRetentionDays` (#234) |
| `page_links.target_slug` | Slug-Text auch nach Ziel-Purge | bewusst behalten (#235, Restrisiko I-24) |
| `audit_log` | Metadaten (nie Inhalt) | `audit.retentionDays` (#196) |
| `attachments` (Row) | Metadaten + SHA-256 | Purge der Seite/des Ponds |
**Auf Volumes:**
| Ort | Inhalt | Lebenszyklus |
| --------- | -------------------------------------------- | --------------------------------------------------------------- |
| `uploads` | Attachment-Bytes | Purge löscht Datei + Quota; Orphan-Sweep (#194) räumt Verwaiste |
| `backups` | nächtliche Restore-Sets (Dump + Volume-Tars) | `BACKUP_RETENTION_DAYS` |
| `plugins` | Plugin-Pakete (kein Seiteninhalt) | Uninstall |
| `secrets` | Secret-Store (SMTP etc., kein Seiteninhalt) | Betreiber |
**Außerhalb der Instanz (nicht von Anwendungs-Löschung erreichbar —
Löschkonzept muss sie benennen):**
| Ort | Inhalt | Kontrolle |
| ----------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| Backup-Spiegel (BASEL/WebDAV) | vollständige Restore-Sets | Betreiber; Retention auf Zielsystem |
| Export-Artefakte | PDF/DOCX/ODT/ZIP beim Nutzer | organisatorisch (VS-Handhabung, Kennzeichnung M26) |
| Browser-IndexedDB | Offline-Kopie zuletzt geöffneter Seiten | Endgeräteschutz (Restrisiko I-25) |
| Feed-Reader / API-Konsumenten | abonnierte Inhalte | organisatorisch; Referenzkonfiguration schaltet Feeds/API ab |
| Mails beim Empfänger | Digest-/Benachrichtigungstexte (Titel) | Restrisiko I-23; Referenzkonfiguration ohne SMTP |
| Container-stdout-Logs | Metadaten inkl. Audit-Zeilen (nie Seiteninhalt, security.md §Logging) | Docker json-file mit Rotation; Collector des Betreibers |
## 6 Kryptografie-Inventar (Bestand, kein Anspruch)
Zur Einordnung (vollständige Liste: Abgrenzungserklärung §3): Argon2id
für Passwort-Hashes, HMAC-signierte Kurzzeit-Tokens über `jose` mit
HKDF-Zweckableitung (#188), SHA-256-Integritätshashes für Attachments
(#199, fail-closed beim Download). Keine Inhalts- oder
Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
(§52 VSA).
## 7 Lesetrail: Zweckbindung (Issue #225, ADR 0023)
Der Lesetrail ist abschaltbar (`readTrail.enabled`, **Default: aus**);
die Referenzkonfiguration (Härtungsleitfaden §1.1) schaltet ihn ein.
Aus heißt: es wird **nirgends** ein Ereignis geschrieben — keine
Tabellenzeile, keine Logzeile; die api meldet die Schalterstellung
einmal beim Start, damit ein ereignisloser Trail nie zweideutig ist.
Diese Zweckbindung ist Teil der Betriebsdokumentation und gegenüber
der Personalvertretung offenzulegen.
**Was aufgezeichnet wird:** je (Sitzung, Seite, Kanal) und Dedup-Fenster
(§3.5) ein Ereignis mit Zeitstempel, Konto-Id (oder dem Marker `anon`),
Sitzungsschlüssel, Seiten- und Teich-Id, Kanal und der Einstufung zum
Lesezeitpunkt. **Kein** Seiteninhalt, **keine** Titel, **keine**
IP-Adressen, **kein** Fingerprinting anonymer Leser.
**Warum:** ausschließlich Beweissicherung für Lesezugriffe auf **als
VS-NfD gekennzeichnete** Inhalte (§52-konforme Nachvollziehbarkeit:
„welche eingestufte Seite wurde wann über welchen Kanal gelesen") —
Plattform-Logs kennen URLs, nicht Einstufungen. Zugriffe auf nicht
eingestufte Inhalte werden **nie** erfasst (Variante A, ADR 0023).
**Wer lesen darf:** ausschließlich Site-Admins über
`GET /admin/system/read-events` (kein UI-Panel — Prüfwerkzeug, kein
Alltagsbildschirm). Organisatorische Beschränkung auf den
Sicherheitsbeauftragten: Betreibersache (Betriebshandbuch §6
Rollentrennung).
**Wie lange:** `readTrail.retentionDays` (Default 365 Tage); Löschläufe
sind selbst auditiert (`read_trail.pruned`), sodass jede Lücke erklärbar
ist.
**Wofür NICHT:** keine Leistungs- oder Verhaltenskontrolle der
Beschäftigten, keine Auswertung von Arbeitsmustern, keine
Anwesenheitskontrolle, keine Weitergabe außerhalb des
Sicherheitsvorfalls- bzw. Prüfkontexts. Die Beschränkung auf
eingestufte Inhalte ist die technische Absicherung dieser Zusage: was
nicht gekennzeichnet ist, erzeugt keine Spur.