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
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>
278 lines
19 KiB
Markdown
278 lines
19 KiB
Markdown
# 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):
|
||
|
||
| 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).
|