dorfteich/docs/vs-nfd/60-sicherheitsdokumentation.md
Claude Fable 5 f0c6af4412
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m0s
CI / Build container images (pull_request) Successful in 1m22s
CI / Auth e2e pack (pull_request) Successful in 9m2s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
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
#228: security documentation (architecture, data flows, network plan)
docs/vs-nfd/60-sicherheitsdokumentation.md: component diagram with per-
service purpose and privileges, network plan digit-exact against the
deploy compose (127.0.0.1-only app bindings, internal-only data zone),
data-flow diagrams (auth, realtime editing incl. LISTEN/NOTIFY and the
60s collab token, export via the pinned sidecars, backup incl. the
ADR-0026 allowlist, and every read channel), named trust boundaries
(reverse proxy, plugin sandbox, outbound SMTP/mirror), and the complete
list of content copies — in-database, on-volume and outside the
instance — that the deletion concept in #229 builds on. Mermaid only,
German (assessor audience), with the maintained-in-same-PR rule stated.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:35:26 +02:00

19 KiB
Raw Permalink Blame History

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

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

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)

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)

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

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 <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 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).