# 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),
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,
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,
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): | 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 | ``-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 geplante Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin trägt der Proxy nur Transport. 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).