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>
19 KiB
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
- 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). - 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.
- Anwendungs- ↔ Datenzone:
db,pandoc,gotenberg,backupsind nur im Docker-Netzinternalerreichbar;webhat keinerlei Zugang dorthin. - 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. - 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).
- 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).