dorfteich/docs/vs-nfd/60-sicherheitsdokumentation.md
Claude Fable 5 7ce6467385
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 5s
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
#225: read-trail master switch and written purpose limitation
New instance switch readTrail.enabled, default OFF: read logging is
employee monitoring in a works council's eyes — an ordinary instance
must not surveil reads. Off means no event is written ANYWHERE (no row,
no stdout line, verified by test); the api announces the switch position
once per boot, so an eventless trail is never ambiguous — a gap reads
as "was off", never "was lost".

The written purpose limitation ships as section 7 of the VS-NfD
security documentation (#228): what is recorded (no content, no titles,
no IPs, no fingerprinting), why (evidence for reads of marked content
only — variant A is the technical anchor of the promise), who may read
it (Site Admin, API-only), for how long (readTrail.retentionDays,
audited pruning), and what it may NOT be used for (no performance or
behaviour monitoring). The hardening guide's reference configuration
turns the trail on (reference value true) and points to that text; the
existing trail suites now enable the switch explicitly.

Refs #225.

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

22 KiB
Raw 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). 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 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).

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.