Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings, all verified against the code), the prioritized action plan rev. 2 with issue references written back to every checkbox, the two-stage issue/ADR brief, and the full reviewed draft used to create the forge state. Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture decisions: no security base functions (par. 52 VSA anchor), HKDF token key separation, external authentication, page classification, read-access audit trail (variant A), reproducible offline deployment, plugin trust model, and backup target restriction. Forge state created alongside this commit: 11 labels, milestones M24-M31, issues #188-#236 (docs-only change, no code touched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
61 KiB
VS-NfD-Ist-Aufnahme Dorfteich
Auftrag:
docs/vs-nfd/00-analyse-auftrag.md. Read-only-Analyse — keine Codeänderungen. Stand: 2026-07-29, Code-Standmain=32c8baa(Prod v0.12.0). Nachgezogen nach dem Maßnahmenplan (20-massnahmenplan.md) und dem Issue-Entwurf (31-issue-entwurf.md): der Plan entstand aus einer Analyse, die nie als Datei abgelegt wurde. Alle Befunde hier sind am Code verifiziert; wo ein Befund den Plan korrigiert, steht es dabei.
Leitprinzip der Bewertung: Dorfteich darf keine Sicherheitsgrundfunktion im Sinne von §52 VSA selbst implementieren. Verschlüsselung, Authentisierung, Netzabschluss und Datenträgerschutz gehören auf die Plattform der Behörde. Jede Stelle, an der die Anwendung selbst schützt statt zu delegieren, ist ein Befund.
Bewertung: OK · ANPASSEN · BLOCKIEREND · UNKLAR. Aufwand: S ≤ 1 AT · M 2–3 AT · L ≥ 4 AT.
1. Management-Zusammenfassung
Dorfteich ist näher am Ziel als bei einem Wiki dieser Größe zu erwarten wäre — aber aus einem Grund, der genau benannt werden muss: Es erbringt kaum Sicherheitsgrundfunktionen, weil es sie schlicht nicht hat. Keine Inhalts- oder Backup-Verschlüsselung, kein eigenes MFA, keine Krypto jenseits von Argon2id-Credential-Hashing und kurzlebigen Token-Signaturen. Das ist unter §52 VSA die richtige Architektur, nicht eine Lücke — und so ist es zu vertreten.
Die drei größten Brocken:
- Authentisierung liegt vollständig in der Anwendung. Es gibt keine
OIDC-Anbindung (nur den vorbereiteten
UserIdentity.provider-Slot) und keinen Weg, die lokale Anmeldung abzuschalten. Solange das so ist, ist Dorfteich Träger einer Sicherheitsgrundfunktion. - Es gibt kein Einstufungskonzept. Seiten haben kein Einstufungsmetadatum, und kein Ausgabekanal kennt einen Aufdruck — Print-CSS fehlt sogar komplett. Kennzeichnung ist die eine VS-NfD-Anforderung, die niemand außer der Anwendung erfüllen kann.
- Der Offline-/Airgap-Betrieb ist plausibel, aber unbelegt. Keine
Telemetrie, keine CDNs, keine Update-Checks, Fonts self-hosted, drawio
vendored — nachweislich. Getestet wurde es nie, und alle Images hängen
an Tags statt an Digests (
gotenberg/gotenberg:8ist ein gleitender Major-Tag).
Nebenbefunde mit Substanz: Rohdokument-Bytes jedes Im-/Exports bleiben
unbefristet in conversion_jobs liegen, der Volltextindex enthält
Papierkorb-Inhalte, getrashte Teiche werden nie endgültig gelöscht, und
Seiteninhalte landen als Yjs-Kopie in der IndexedDB des Endgeräts.
42 Befunde: 5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK.
2. Befundtabelle
Sortiert nach Bewertung. Die Spalte „Handlungsbedarf" nennt in Klammern
das Issue aus 31-issue-entwurf.md, das den Befund adressiert.
BLOCKIEREND
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
|---|---|---|---|---|---|
| I-01 | Fremdauthentisierung fehlt | apps/api/prisma/schema.prisma:562–577; kein oidc-Treffer in apps/api/src |
UserIdentity.provider ist als "password" heute / "oidc:<issuer>" später dokumentiert. Implementiert ist nur password. ADR 0007 erklärt Bereitschaft, nicht Funktion. |
OIDC Auth Code + PKCE gegen den vorhandenen Slot (#214) | L |
| I-02 | Lokale Auth nicht abschaltbar | apps/api/src/auth/, apps/api/src/settings/instance-settings.service.ts |
Kein auth.local.enabled. Muster für harte Schalter existiert (api.enabled, mcp.enabled, beide Default aus), wird für Auth aber nicht genutzt. Betroffen sind auch Reset, Registrierung, PATs, Feed-Tokens. |
Harter Schalter über alle Credential-Flows (#216) | M |
| I-03 | Kein Einstufungsmetadatum | apps/api/prisma/schema.prisma:263–301 |
Page trägt Titel, Slug, Baumposition, Zeitstempel — kein Feld, das ein Schutzniveau ausdrücken könnte. Labels sind kein Ersatz (I-03a im Detailteil). |
Enum-Feld + Vererbung + Herabstufungsrecht (#204, #205) | M |
| I-04 | Kein Einstufungsaufdruck in Ausgaben | apps/web/src (kein @media print), apps/api/src/import-export/pdf-html.ts:76–79, pandoc.converter.ts, export-markdown.ts, apps/api/src/public/html-shell.ts |
Kein Kanal kennt einen Aufdruck. Print-CSS fehlt vollständig — Browserdruck reproduziert die Bildschirmansicht inkl. Navigation. PDF hat einen Kopf einmalig statt je Seite; pandoc läuft ohne Reference-Doc, also ohne Kopf-/Fußzeile. | Sieben Kanal-Issues (#206–#212) | L |
| I-05 | Airgap unverifiziert, Images nur per Tag | deploy/compose/docker-compose.yml:186,206,221,237; .gitea/workflows/ci.yml |
postgres:17.5-alpine, pandoc/core:3.6, gotenberg/gotenberg:8, caddy:2.10-alpine — Tags, keine Digests; gotenberg:8 gleitet über Minor/Patch. Kein Mirror-Verfahren, kein Offline-Update-Pfad, kein Testlauf. |
Digest-Pinning, Mirror, Offline-Build, isolierter Testlauf, Update-Pfad (#203, #218–#221) | L |
ANPASSEN
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
|---|---|---|---|---|---|
| I-06 | Ein Secret für zwei Zwecke | packages/shared/src/env.ts:55,142; apps/collab/src/index.ts:46; apps/api/src/pages/pages.service.ts:383; apps/api/src/notifications/digest.service.ts:161; notifications.controller.ts:51 |
COLLAB_TOKEN_SECRET signiert Collab-Tokens und Unsubscribe-Tokens. Teil-Trennung existiert textuell über ein PURPOSE-Präfix (unsubscribe-token.ts:14), nicht über Schlüssel. |
HKDF-Subkeys je Zweck (#188) | L |
| I-07 | Eigenbau-HMAC-JWT | packages/shared/src/token-crypto.ts:1,37,74 |
Kompakter HS256-JWT handgebaut auf node:crypto; nur HS256, Signatur in konstanter Zeit vor jedem Lesen ungeprüfter Felder. Sorgfältig — aber handgeschriebene Krypto in der Vertrauensgrenze. Grund ist dokumentiert: identischer Code in CommonJS-api und ESM-collab. Weder jose noch jsonwebtoken ist Dependency. |
Ersatz durch jose, Cross-Runtime-Test als Zaun (#188) |
L |
| I-08 | CSRF lässt fehlende Header durch | apps/api/src/auth/auth.guard.ts:108–112 |
assertSameOrigin nimmt origin ?? referer; fehlen beide, kehrt die Prüfung ohne Entscheidung zurück. Kommentar nennt die Absicht (Nicht-Browser-Clients; SameSite als eigentliche Abwehr). Fail-open bleibt es trotzdem. |
Fail-closed mit dokumentierter Ausnahme für Bearer-Clients (#189) | S |
| I-09 | Session 30 Tage, hart kodiert | apps/api/src/auth/sessions.service.ts:8,31,50; apps/api/src/auth/auth.guard.ts:58 |
SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // sliding 30 days, bei jeder Berührung erneuert; Cookie-maxAge gleich. Kein Idle-Timeout — lastSeenAt wird geschrieben, aber nie als Grenze ausgewertet. Nicht konfigurierbar. |
Absolute + Idle-Grenze konfigurierbar, Default deutlich darunter (#190) | M |
| I-10 | Feed-Token im Query-Parameter | apps/api/src/public/public.controller.ts:28,43; feed.service.ts:25 |
Langlebiges Lese-Credential als ?token=… — landet in Proxy-Logs, Historie, Referrer. Speicherung ist gehasht (feed-tokens.service.ts:73), das Problem ist der Transport. Kein Instanzschalter für Feeds. |
Token aus dem Query holen oder feeds.enabled (#191) |
M |
| I-11 | Backup-Ziel frei wählbar | apps/api/src/settings/instance-settings.service.ts (backup.nextcloud.baseUrl); apps/api/src/backup/backup-target.service.ts:3,65; apps/api/src/admin/backup-admin.service.ts:27,112,146; apps/backup/src/mirror.ts |
WebDAV-Ziel ist als URL validiert, aber auf keinen Host beschränkt; zweiter Remote-Pfad ist der rsync-Mirror. Keine Allowlist, kein Deploy-Kill-Switch. Wer Site-Admin hat, kann eine Vollkopie der Instanz umleiten. | Deploy-Allowlist, leere Liste = nur lokal (#192) | M |
| I-12 | Kein Pond-Purge | apps/api/prisma/schema.prisma:175,186,277,432,553; apps/api/src/trash/trash.service.ts |
Pond.deletedAt bildet den Teich-Papierkorb; endgültiges Löschen gibt es nur für Seiten (purgeNow:96, purgeDuePages:104, purgePage:121). Page.pond, Attachment.pond und Label.pond haben keine onDelete-Aktion → Prisma-Default Restrict blockt das Löschen ohnehin. Getrashte Teiche bleiben unbegrenzt liegen. |
Pond-Purge über alle abhängigen Tabellen (#193) | M |
| I-13 | Kein Orphan-File-Sweep | apps/api/prisma/schema.prisma:530–538; registrierte Jobs in apps/api/src/*/*.module.ts |
Das Schema sagt es selbst: der pageId-Link wird nicht angefasst, wenn ein Bild später aus dem Inhalt entfernt wird — „an orphan-file sweep … is a separate future maintenance job"; „deletedAt stays unused for now". Von fünf registrierten Jobs (version-thinning, page-compaction, trash-purge, data-export-purge, notification-digest) ist keiner der Sweep. |
Sweep implementieren, deletedAt nutzen oder entfernen (#194) |
M |
| I-14 | Papierkorb im Suchindex | apps/api/src/search/postgres-search.provider.ts:41,73,142–143 |
Der gewichtete tsvector liegt auf page_content_cache.search_vector; getrashte Inhalte bleiben im Index und werden nur query-seitig ausgeblendet (p.deleted_at IS NULL, po.deleted_at IS NULL). Ein künftiger Abfragepfad ohne diesen Filter leakt Inhalt. |
Vektor beim Trashen leeren, Query-Filter als zweite Ebene behalten (#195) | M |
| I-15 | Keine Retention für audit_log |
apps/api/prisma/schema.prisma:74–91; apps/api/src/audit/audit.service.ts |
Persistenter Trail (AuditEntry, Issue #86) wächst unbegrenzt; kein Pruning-Job registriert. |
Konfigurierbare Aufbewahrung + Job (#196) | S |
| I-16 | Keine Security-Response-Header | apps/api/src/main.ts; apps/api/package.json |
helmet ist keine Dependency und kommt in apps/api/src nicht vor. app.enableCors() wird nicht aufgerufen — CORS ist damit implizit restriktiv (keine CORS-Header, Browser blockt cross-origin), aber nirgends als Entscheidung festgehalten. Der Web-Tier hat eine strenge CSP; die api-Antworten selbst sind die Lücke. |
HSTS, X-Content-Type-Options, Referrer-Policy, Frame-/Permissions-Policy, CORS explizit (#197) |
S |
| I-17 | Kein SBOM, kein Lizenzreport | .gitea/workflows/ci.yml; kein sbom/syft/cyclonedx in .gitea/ oder package.json |
CI macht install, build, lint, typecheck, test, i18n:check. Kein Lieferketten-Artefakt. (Die Lizenzlage selbst ist unkritisch, s. I-37.) |
CycloneDX je Image + Workspace, Lizenzreport als Artefakt (#202) | M |
| I-18 | Keine Attachment-Integritätshashes | apps/api/prisma/schema.prisma:541–559; apps/api/src/files/file-storage.service.ts:21,25 |
Attachment hat keine Checksumme; die einzigen Hashes im Schema sind Credential-/Token-Hashes. Dateien liegen unter UPLOADS_DIR/<pondId>/<fileId>. Manipulation am Dateisystem ist nicht erkennbar. |
SHA-256 beim Upload, Prüfung beim Download, Backfill (#199) | M |
| I-19 | Plugins nicht instanzweit abschaltbar | apps/api/src/settings/instance-settings.service.ts; apps/api/src/public-api/public-api.guard.ts:64; apps/api/src/mcp/mcp.controller.ts:50,84 |
Für Public-API und MCP gibt es harte Schalter mit 404-Semantik, für Plugins nicht. Plugin-Zustand ist installierte Menge + Freischaltung je Teich. „Codeausführung in der VS-Zone" ist damit nicht mit einem Schalter beantwortbar. | plugins.enabled = false nach demselben Muster (#200) |
M |
| I-20 | Ereigniskatalog nicht stabil | apps/api/src/audit/audit.service.ts:9; 37 Aufrufstellen in apps/api/src |
AuditEvent.action: string — der Doc-Kommentar nennt es „stable dot-namespaced id", erzwungen wird nichts. 34 verschiedene Ids sind in Gebrauch (auth.login_failed, grant.created, plugin.installed, settings.changed, …). Ohne Vertrag brechen SIEM-Regeln beim Update. |
Typisierte Union + veröffentlichter Katalog + Zaun-Test (#201) | L |
| I-21 | Keine Lesezugriffsprotokollierung | apps/api/src/audit/audit.service.ts |
Bewusst begrenzt: „Content activity (pages, files, exports, labels) intentionally stays log-only — the trail answers 'who changed access/configuration', not 'who edited what'." Lesezugriffe existieren gar nicht. Das einzige action: 'read' (apps/api/src/mcp/mcp.service.ts:243) ist ein Permission-Parameter, kein Ereignis. |
Lesetrail nur für eingestufte Inhalte, Variante A (#222–#225) | L |
| I-22 | Rohdokument-Bytes ohne Pruning | apps/api/prisma/schema.prisma:746–784; apps/api/src/import-export/import-export.module.ts:62 |
ConversionJob.input/result sind die rohen Dokumentbytes jedes Im-/Exports. Das Schema nennt sie „transient, not the durable copy" und verweist auf „a later maintenance job". Registriert ist nur data-export-purge, und expiresAt ist laut Schema „Null for every other job kind, whose result never expires". Für export_docx/import_docx bleiben die Bytes damit unbefristet. |
Pruning für alle Job-Arten; im Löschkonzept (#229) ausweisen | M |
| I-23 | mail_outbox ohne Retention, mit Seitentiteln |
apps/api/prisma/schema.prisma:683–697; apps/api/src/notifications/digest.service.ts:16,125,146 |
Die Outbox speichert textBody/htmlBody dauerhaft; kein Pruning-Job. Digest-Mails enthalten Seitentitel und Akteursnamen (- ${page.pageTitle}: … (${actorNames})). Transaktionsmails selbst tragen keinen Inhalt (mail-templates.ts:25–44: Anrede + i18n-Text + Link). |
Retention für mail_outbox; im Löschkonzept ausweisen |
S |
| I-24 | Slug-Residuum nach Purge | apps/api/prisma/schema.prisma:441–461 |
PageLink.fromPage kaskadiert, toPage ist onDelete: SetNull — nach dem Purge bleibt in fremden Seiten eine Zeile mit target_slug der gelöschten Seite (toPageId genullt). Der Titel/Slug einer eingestuften Seite kann selbst eingestufte Information sein. |
Bewusst entscheiden: Zeilen mitlöschen oder als Restrisiko (#231) führen | S |
| I-25 | Inhaltskopie auf dem Endgerät | apps/web/src/editor/use-collab-provider.ts:32,76,134–141,184 |
Jede geöffnete Seite wird als Yjs-Dokument in die IndexedDB gespiegelt (dorfteich-page-<pageId>, Issue #38, ADR 0003). Beim Verlassen wird sie gelöscht, wenn der Server synchronisiert hat (clearData()); bei unsynchronisierten Offline-Änderungen bleibt sie absichtlich stehen. Bricht der Browser vorher ab, läuft die Aufräum-Routine nicht. Für Entzugsfälle gibt es discardLocal (#39). |
Nicht „keine Spuren auf dem Endgerät" behaupten; Endgeräteverschlüsselung als Betreiberpflicht dokumentieren (#226, #231) | S |
| I-26 | Node-Version nicht gepinnt | package.json:7–10 |
"engines": { "node": ">=22" } ist eine Untergrenze, kein Pin; packageManager: "pnpm@11.9.0" ist exakt. Für einen reproduzierbaren Offline-Build fehlt die Node-Festlegung. |
Node-Version pinnen und im Build-Verfahren nennen (#219) | S |
UNKLAR
| Nr. | Thema | Fundort | Ist-Zustand | Warum unklar |
|---|---|---|---|---|
| I-27 | Herkunft/Maintainer-Status der Abhängigkeiten | pnpm-lock.yaml (1380 aufgelöste Pakete, 366 davon prod) |
Lizenzlage geprüft (I-37), Jurisdiktion nicht. | Die npm-Registry weist keine Rechtsträger oder Jurisdiktionen aus. Eine belastbare Antwort braucht Recherche je Paket; für die Kernpakete (React/Meta, TipTap/überwiegend DE-Umfeld, Yjs, NestJS, Prisma) ist sie machbar, für 366 Pakete nicht mit vertretbarem Aufwand. Vorschlag: Kernabhängigkeiten einzeln, Rest über den SBOM (#202) offenlegen. |
| I-28 | Verhalten ohne Internetzugang | s. I-05, I-31 | Kein Codepfad baut eine Verbindung nach außen auf außer Sidecars, SMTP und Backup-Ziel. | Ob eine egress-geblockte Instanz vollständig funktioniert, ist eine Laufzeitfrage (Container-Pulls, DNS, Zertifikatsprüfungen, SMTP-Timeouts). Nur durch den Testlauf (#220) beantwortbar. |
| I-29 | Wirksamkeit der Sicherheitsschalter bei laufendem Betrieb | apps/api/src/settings/instance-settings.service.ts |
Der Settings-Cache der api ist in-process; ein DB-Write an instance_settings wirkt erst nach api-Neustart. |
Für api.enabled/mcp.enabled ist das dokumentiert und betrieblich beherrschbar. Für einen künftigen plugins.enabled (#200) oder einen Auth-Schalter (#216) muss geklärt werden, ob „Schalter umgelegt" auch „sofort wirksam" heißt — sonst ist die Referenzkonfiguration zeitweise unwahr. |
| I-30 | Vollständigkeit der Ausgabekanal-Liste | apps/api/src/import-export/, apps/api/src/public/, apps/api/src/public-api/, apps/api/src/files/ |
Gefunden wurden: Web-Ansicht, Browserdruck, PDF (Gotenberg), DOCX/ODT (pandoc), Markdown-Einzeldownload, Pond-ZIP, Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse, No-JS-Shell, Anhang-Download, Datenexport (DSGVO), Backup. | Die Liste ist durch Codelesen entstanden, nicht durch eine erschöpfende Routen-Enumeration. Der vorhandene Route-Enumeration-Test des Permission-Modells wäre die Grundlage, das mechanisch zu belegen — empfohlen als Teil von #211. |
OK — Stärken, die zu belegen sind
| Nr. | Thema | Fundort | Ist-Zustand |
|---|---|---|---|
| I-31 | Keine ausgehenden Verbindungen | apps/api/src/health/readiness.service.ts:83,102; apps/api/src/import-export/gotenberg.renderer.ts:59,85; pandoc.converter.ts:102,121; apps/api/src/mail/smtp-config.service.ts:54,71; apps/backup/src/remote.ts, mirror.ts |
Jeder ausgehende Aufruf im Code geht an einen internen Sidecar (PANDOC_URL, GOTENBERG_URL), an SMTP oder an das konfigurierte Backup-Ziel. Keine Telemetrie, keine Update-Checks, keine Analytics, kein Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed, keine Avatar-Dienste, keine externen Karten, Fonts self-hosted (ADR 0016). Das ist der stärkste Einzelbefund des ganzen Berichts. |
| I-32 | Passwort-Hashing | apps/api/src/users/password.ts:1,9–25 |
Argon2id über die argon2-Bibliothek, mit needsRehash-Pfad. Kein Eigenbau. |
| I-33 | Zentrales Berechtigungsmodell | apps/api/src/permissions/; apps/api/src/mcp/mcp.service.ts:232–253 |
Default-closed, deny-wins, zentral durchgesetzt, mit Route-Enumeration-Test. Der MCP-Endpoint divergiert nicht: er ruft PermissionService.hasPondRole / canAccessPage; eigenständig sind nur seine Schalter. Korrigiert eine offene Frage des Maßnahmenplans. |
| I-34 | Volltextsuche ohne externe Engine | apps/api/src/search/postgres-search.provider.ts:40–41 (ADR 0010) |
Postgres-tsvector. Kein Elasticsearch, kein zweiter Datenhalter, keine weitere Netzwerkgrenze. |
| I-35 | Single-Tenant | apps/api/prisma/schema.prisma (33 Modelle, kein Tenant/Organization) |
Teiche sind Container innerhalb einer Instanz. Passt zur empfohlenen Betriebsform „eine Instanz je Einstufungsniveau". |
| I-36 | Service Worker cached keine Inhalte | apps/web/vite.config.ts:12–20 |
VitePWA precacht ausschließlich Build-Assets; kein Runtime-Caching, navigateFallbackDenylist schließt /api und /collab aus. API-Antworten landen nie im SW-Cache. |
| I-37 | Lizenzlage durchweg permissiv | pnpm licenses list --prod |
366 Produktionspakete, 11 verschiedene Lizenzen: MIT 301, ISC 24, Apache-2.0 22, BSD-3-Clause 5, BlueOak-1.0.0 4, BSD-2-Clause 4, MIT-0 2, Python-2.0 1, CC0-1.0 1, 0BSD 1, (MPL-2.0 OR Apache-2.0) 1. Kein GPL/AGPL, kein Copyleft mit Verteilungsfolgen. Projektlizenz MIT. |
| I-38 | Log-Redaction | apps/api/src/app.module.ts:84–93; apps/collab/src/logger.ts |
pino-JSON auf stdout, LOG_LEVEL konfigurierbar, redact: { paths: ['req.headers.authorization','req.headers.cookie'], remove: true }. Kein Datei- oder Syslog-Transport — Weiterleitung ist Sache der Container-Runtime, was für SIEM-Anbindung die richtige Arbeitsteilung ist. |
| I-39 | Public-API und MCP standardmäßig aus | apps/api/src/settings/instance-settings.service.ts (api.enabled / mcp.enabled, je .default(false)); public-api.guard.ts:64; mcp.controller.ts:50,84 |
Beide Oberflächen antworten im ausgeschalteten Zustand mit 404 (Existenz verstecken), zusätzlich muss jeder Teich einzeln zustimmen. Vorbildliches Muster für I-02 und I-19. |
| I-40 | drawio ist vendored | packages/plugins/drawio/vendor/drawio-30.3.6/; packages/plugins/drawio/manifest.json |
Kein externer Editor-URL: das Manifest nennt kind: code, permissions: ["blockData","ui"], fallback, license: Apache-2.0; homepage ist reines Metadatum. Der drawio-Webapp-Baum liegt im Repo. Korrigiert eine offene Frage des Maßnahmenplans (kein Ausschlusskriterium). |
| I-41 | Entzug beendet laufende Sitzungen | apps/collab/src/index.ts:62–70; apps/collab/src/server.ts:108–125; apps/api/src/pages/pages.service.ts:57,384 |
Collab-Tokens leben 60 Sekunden (COLLAB_TOKEN_TTL_SECONDS = 60); die WS-Ebene autorisiert nur gegen Signatur und claims.pageId === documentName, ohne Permission-Kontext. Das ist tragfähig, weil die api bei jedem Token neu prüft und ein Grant-Entzug offene Verbindungen sofort per pg_notify-Access-Listener schließt (#39). Für den Lesetrail folgt daraus: die api ist der richtige Emissionsort, mit natürlicher Minutengranularität. |
| I-42 | Seiten-Purge ist vollständig | apps/api/src/trash/trash.service.ts:121–144; Kaskaden in apps/api/prisma/schema.prisma:114,313,344,361,373,409,469,486 |
purgePage löscht Anhänge (Datei + Quota), Content-Cache, Update-Log und Watches explizit; Kommentare, Versionen, Mentions, Pending-Contributors, Labels-Zuordnungen und Favoriten hängen an onDelete: Cascade. Kindseiten werden bewusst an den Großeltern-Knoten gehoben. Residuen bleiben nur laut I-24. |
3. Detailbefunde
3.1 Sicherheitsgrundfunktionen
(1) Authentisierung. Es gibt eine eigene Benutzer- und
Credential-Haltung: User plus UserIdentity mit
@@unique([provider, subject]); credential hält den Argon2id-Hash
(schema.prisma:562–577). Gehasht wird mit argon2id über die
argon2-Bibliothek inklusive needsRehash (users/password.ts:9–25) —
kein Eigenbau, korrektes Verfahren. Eine OIDC-, SAML- oder
LDAP-Anbindung existiert nicht; ein Suchlauf über apps/api/src und
packages/shared/src findet oidc ausschließlich im Schema-Kommentar.
Unterstützung für Client-Zertifikate oder vertrauenswürdige
Reverse-Proxy-Header ist ebenfalls nicht vorhanden. → I-01, I-02.
(2) Session-Verwaltung. Eigene Implementierung, nicht Framework:
Session als serverseitige Tabelle, deren id der SHA-256-Hash des
Session-Tokens ist (schema.prisma:580–595, sessions.service.ts:84) —
ein gestohlener DB-Dump gibt keine gültigen Tokens her. Das Cookie ist
httpOnly, sameSite: 'lax', secure in Produktion
(auth.guard.ts:52–58). Gültigkeit: gleitende 30 Tage, hart kodiert,
ohne Idle-Grenze. → I-09.
(3) Kryptographie. Vollständige Inventur der eigenen Krypto-Aufrufe:
| Zweck | Verfahren | Fundort |
|---|---|---|
| Passwörter | Argon2id | users/password.ts:9–25 |
| Session-Ids | SHA-256 über das Token | auth/sessions.service.ts:84 |
| Auth-Tokens (Mail-Flows) | SHA-256 | auth/auth-tokens.service.ts:55 |
| PATs | SHA-256 | public-api/api-tokens.service.ts:149 |
| Feed-Tokens | SHA-256 | public/feed-tokens.service.ts:73 |
| Collab-Tokens | HS256-JWT, handgebaut, TTL 60 s | shared/token-crypto.ts:1,37,74; pages.service.ts:57 |
| Unsubscribe-Tokens | HMAC-SHA-256 mit PURPOSE-Präfix |
notifications/unsubscribe-token.ts:14,40 |
Nirgends werden Inhalte ver- oder entschlüsselt. Es gibt keine Backup-Verschlüsselung und keine Verschlüsselung in DB oder Dateisystem. Unter §52 VSA ist das die gewollte Arbeitsteilung. Die beiden Befunde betreffen nicht das Ob, sondern die Hygiene: ein Secret für zwei Zwecke (I-06) und handgeschriebenes JWT (I-07).
(4) Zugriffskontrolle. Zentral in apps/api/src/permissions/,
default-closed mit deny-wins, per Guards durchgesetzt und durch einen
Route-Enumeration-Test abgesichert. Die geprüften Umgehungskandidaten sind
sauber: der Export filtert über permissions.filterPages
(export.service.ts:102–108), die Suche joint auf lebende Seiten und
Teiche (postgres-search.provider.ts:142–143), der MCP-Endpoint nutzt den
zentralen Service (I-33), und die Collab-WS-Ebene ist über kurzlebige
Tokens plus Entzugs-Listener abgesichert (I-41). Public-API und MCP sind
zusätzlich zweifach gegated (Instanz + Teich) und standardmäßig aus
(I-39). Ratenbegrenzung existiert als Decorator-Mechanik
(rate-limit/rate-limit.guard.ts:24).
(5) Integrität. Keine Prüfsummen auf Anhängen (I-18), keine
Signaturen auf Plugin-Bundles (manifest.json hat kein Hash-Feld), kein
Manipulationsschutz auf dem audit_log. Prüfsummen existieren nur als
Token-Hashes, also zur Authentisierung, nicht zur Integritätssicherung von
Nutzdaten.
3.2 Datenhaltung
(6) Seiteninhalte liegen ausschließlich in Postgres, aber in fünf Repräsentationen:
pages.ydoc_state(Bytes) — das lebende Yjs-Dokumentpage_updates— das Update-Log; gemergt erst bei Compactionpage_versions.ydoc_snapshot— vollständige, selbstständige Snapshots (AUTO/MANUAL/PRE_RESTORE), damit ein Restore nie vom Update-Log abhängt (schema.prisma:318–347)page_content_cache—plain_text,markdown,html,outline(jsonb) — vier abgeleitete Klartext-Formen, bei jedem Save erneuert (schema.prisma:392–411)page_content_cache.search_vector— der gewichtetetsvector
Für ein Löschkonzept ist Punkt 4 der wichtigste: der Klartext jeder Seite liegt vierfach vor, unabhängig vom Yjs-Zustand.
(7) Anhänge liegen im Dateisystem unter
UPLOADS_DIR/<pondId>/<fileId> (file-storage.service.ts:21,25,
Default ./data/uploads), mit Metadaten in attachments. Keine
Verschlüsselung, keine Prüfsumme.
(8) Volltextindex: Postgres-FTS, persistiert auf derselben Zeile wie
der Klartext (page_content_cache), GIN-Index per Raw-SQL-Migration.
Er enthält damit zwangsläufig Klartext der Inhalte — und Papierkorb-Inhalte
(I-14).
(9) Weitere Kopien im Betrieb:
| Kopie | Fundort | Lebensdauer |
|---|---|---|
conversion_jobs.input / .result — rohe Dokumentbytes jedes Im-/Exports |
schema.prisma:746–784 |
unbefristet außer für Datenexport-Jobs (I-22) |
mail_outbox.text_body / .html_body — Digest-Mails mit Seitentiteln |
schema.prisma:683–697 |
unbefristet (I-23) |
| IndexedDB je geöffneter Seite auf dem Endgerät | use-collab-provider.ts:76 |
bis zum Verlassen, bei unsynchronisierten Änderungen länger (I-25) |
comments, notifications |
schema.prisma:100–164 |
an Seite/Nutzer gekoppelt, kaskadierend |
| Backups | apps/backup/, ADR 0015 |
Retention konfigurierbar |
| PWA-Service-Worker | vite.config.ts:12–20 |
enthält keine Inhalte (I-36) |
Thumbnails, Vorschaubilder oder serverseitige Temp-Dateien gibt es
nicht: eine Suche nach tmpdir/mkdtemp//tmp in apps/api/src bleibt
leer — die Konverter arbeiten über HTTP gegen die Sidecars.
(10) Revisionshistorie: page_versions mit vollständigen Snapshots,
Trigger-Enum (AUTO/MANUAL/PRE_RESTORE), contributor_ids als Array;
angelegt von collab bei Sitzungsende und im Bearbeitungsintervall, von der
api auf Zuruf. Ausgedünnt durch den registrierten Job
version-thinning.
3.3 Löschen und Vernichtung
(11) Seiten werden zweistufig gelöscht: deletedAt setzt den
Papierkorb (ADR 0013), der Job trash-purge löscht nach Retention
endgültig, zusätzlich gibt es „Purge einzeln" (trash.controller.ts:26).
(12) Vom Purge erfasst (I-42): Anhänge inklusive Datei und
Quota-Rückgabe, Content-Cache (und damit der Suchvektor), Update-Log,
Watches — explizit; Kommentare, Versionen, Mentions,
Pending-Contributors, Label-Zuordnungen und Favoriten über
onDelete: Cascade. Kindseiten werden bewusst an den Elternknoten der
gelöschten Seite gehoben.
(13) Rückstandsfrei? Nein — vier konkrete Reste:
page_links-Zeilen fremder Seiten behalten dentarget_slugder gelöschten Seite,to_page_idwird genullt (I-24).conversion_jobsbehalten die Rohbytes jedes Exports dieser Seite (I-22).mail_outboxbehält Digest-Mails mit dem Seitentitel (I-23).- IndexedDB-Kopien auf Endgeräten, die die Seite offline geöffnet hatten (I-25).
Für Teiche gibt es überhaupt kein endgültiges Löschen (I-12) — und die FK-Restriktionen würden es derzeit auch blockieren. Backups sind ein fünfter, gewollter Rest mit eigener Retention.
3.4 Ausgehende Verbindungen
(14)–(15) Die vollständige Liste der Stellen, an denen die Anwendung
nach außen geht, ist in I-31 aufgeführt: zwei interne Sidecars (pandoc,
Gotenberg), SMTP, Backup-Ziel (WebDAV und/oder rsync). Sonst nichts.
Keine Update-Checks, keine Telemetrie, kein Analytics, kein
Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed,
keine Avatar-Dienste, keine externen Karten, Schriften oder Skripte.
Die gepinnten Fremdmodule laden zur Laufzeit nichts nach: drawio ist
vendored (I-40), Mermaid und Excalidraw sind gebündelte
Workspace-Pakete, und die CSP default-src 'self' würde einen Nachlader
ohnehin blocken.
(16) Backups: Ziel ist frei konfigurierbar, ohne Allowlist; nicht verschlüsselt (I-11). Letzteres ist gewollt (§52 VSA), Ersteres nicht.
(17) Betrieb ohne Internet: Codeseitig nichts entgegen, empirisch
unbelegt (I-28). Offene Einzelfrage mit Betriebsrelevanz: ohne
ausgehendes SMTP funktionieren Registrierung, Verifikation und
Passwort-Reset nicht — was mit auth.local.enabled = false (#216)
zusammenfällt und dort mitentschieden werden sollte.
3.5 Ausgabekanäle
(18) Gefundene Wege, auf denen Inhalte die Anwendung verlassen
(Vollständigkeit s. I-30): Web-Ansicht (SPA), Browserdruck, PDF über
Gotenberg, DOCX/ODT über pandoc, Markdown-Einzeldownload
(pages.controller.ts:122), Pond-ZIP (export.service.ts:68),
Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse
(Snippets), No-JS-Shell (public/html-shell.ts), Anhang-Download
(files.controller.ts:51,71), DSGVO-Datenexport, Backup.
(19) Metadatenmodell: Page trägt Titel, Slug, parentId, sortKey,
Zeitstempel, Ersteller — nichts, wo eine Einstufung hineinpasste (I-03).
Der naheliegende Ersatz „Labels" trägt nicht, und zwar aus vier
belegbaren Gründen (I-03a):
Labelist teich-gebunden (pondId,schema.prisma:423–437) — dieselbe Einstufung wäre in jedem Teich ein anderes Objekt ohne instanzweite Bedeutung.- Labels sind von jedem Editor bearbeitbar.
- Labels vererben nicht im Seitenbaum.
- Labels verlassen die Anwendung nie:
export.service.ts:98–105lädtlabelIdsausschließlich fürpermissions.filterPages; kein Export-Pfad schreibt sie in die Ausgabe. Das korrigiert eine offene Frage des Maßnahmenplans mit einem klaren Nein.
(20) Eingriffspunkte für einen Kopf-/Fußaufdruck — konkret:
| Kanal | Eingriffsstelle | Zustand |
|---|---|---|
| Web-Ansicht | Seiten-Layout der SPA | kein Aufdruck-Element |
| Browserdruck | fehlt komplett — kein @media print in apps/web/src |
Neubau nötig, @page-Randboxen für Wiederholung je Blatt |
import-export/pdf-html.ts:76–79 (Dokumentkopf, einmalig) + Gotenberg-Footer-Template (:31, liefert heute Seitenzahlen) |
Mechanik für „je Seite" existiert, wird für den Aufdruck nicht genutzt | |
| DOCX/ODT | import-export/pandoc.converter.ts |
kein --reference-doc — es gibt keine Kopf-/Fußzeilendefinition, in die der Aufdruck gehörte |
| Markdown-ZIP | import-export/export-markdown.ts |
keine YAML-Frontmatter-Ausgabe (Frontmatter existiert nur im Import, obsidian-vault.ts:203,528) |
| Atom-Feeds | public/feed.service.ts |
kein Feld |
| Public-API | public-api/ |
Seitenrepräsentation ohne Einstufung |
| Suchergebnisse | search/postgres-search.provider.ts |
Snippets ohne Markierung |
| No-JS-Shell | public/html-shell.ts |
eigener Renderpfad, braucht eigene Behandlung |
| Anhang-Download | files.controller.ts:51,71 |
Dateiname/Begleitdatei sind die einzigen Träger |
3.6 Protokollierung
(21) Protokolliert werden Authentisierungs- und Admin-Ereignisse
(34 Ids, s. I-20) — persistent in audit_log und als
audit: …-stdout-Zeile. Inhaltsaktivität bleibt bewusst log-only,
Lesezugriffe fehlen ganz (I-21).
(22) Wohin: pino-JSON auf stdout, kein Datei- oder Syslog-Transport (I-38). Ein SIEM-Export ist damit über die Container-Runtime möglich und braucht keinen anwendungsseitigen Syslog-Client — die Lücke ist nicht der Transport, sondern der stabile Ereigniskatalog (I-20).
(23) Aufbewahrungsdauer: für audit_log nicht konfigurierbar und
ohne Job (I-15); für Backups konfigurierbar
(BACKUP_RETENTION_DAYS, backup.localRetentionDays,
backup.remoteRetentionDays); LOG_LEVEL konfigurierbar, Log-Retention
ist Sache der Runtime.
(24) Unerwünschte Daten in Logs: authorization- und
cookie-Header werden entfernt (app.module.ts:93); der
AuditEvent.details-Kommentar verlangt „never secrets, tokens, or page
content". Seitentitel und Nutzernamen erscheinen in Logs und
Digest-Mails (I-23) — für eine eingestufte Seite kann bereits der Titel
schützenswert sein. Das gehört in die Restrisikoliste, nicht in einen
Bugfix.
3.7 Lieferkette
(25) Abhängigkeiten: 1380 aufgelöste Pakete im Lockfile, davon 366
im Produktionsbaum. Direkte Produktionsabhängigkeiten: api 31,
web 26, collab 7, shared 5, backup 5. Die Kernpakete sind
NestJS 11, Prisma 6, Postgres-Client pg 8, Argon2, nodemailer 9,
pino 9, Yjs 13 mit Hocuspocus 4, TipTap 3 und ProseMirror, React 19,
@modelcontextprotocol/sdk 1. Lizenzverteilung s. I-37 — durchweg
permissiv.
(26) Herkunft: nicht belastbar beantwortbar (I-27).
(27) Pinning: pnpm-lock.yaml ist eingecheckt und deckt transitiv
alle 1380 Pakete; CI installiert mit --frozen-lockfile. Die
Manifest-Ranges sind Caret-Ranges, was in Kombination mit dem Lockfile
korrekt ist. Ungepinnt sind: Container-Image-Digests (I-05) und die
Node-Version (I-26). pnpm ist exakt gepinnt. Randnotiz ohne
Sicherheitsbezug: zod läuft in api/shared als ^3, im Web als ^4.
(28) Deployment: Container-Images per Docker Compose
(deploy/compose/docker-compose.yml) — vier eigene Images (web, api,
collab, backup) plus postgres:17.5-alpine, pandoc/core:3.6,
gotenberg/gotenberg:8, caddy:2.10-alpine (letzteres im Profil
caddy). Migrationen laufen beim api-Start (main.ts:runMigrations,
mit Advisory-Lock und einer Warteschleife gegen laufende Restores).
Eine Offline-Installation ist plausibel und unbelegt (I-05, I-28).
3.8 Betriebsmodell
(29) Mandantenfähigkeit: Single-Tenant (I-35).
(30) Konfiguration. Zwei klar getrennte Ebenen:
- Deploy-Zeit (Env):
DATABASE_URL,APP_BASE_URL,PORT,UPLOADS_DIR,COLLAB_TOKEN_SECRET,SMTP_*,LOG_LEVEL,GOTENBERG_URL,PANDOC_URL,BACKUP_RETENTION_DAYS,MIGRATE_ON_START(packages/shared/src/env.ts). - Laufzeit (
instance_settings):upload.allowedExtensions,upload.svgPolicy,api.enabled,mcp.enabled,backup.*(Retention, Nextcloud-Ziel, Upload-Zeitplan),legal.imprint,legal.privacyPolicy,home.content, Setup-Abschluss.
Wichtig für Sicherheitsschalter: der Settings-Cache ist in-process, eine Änderung wirkt erst nach api-Neustart (I-29).
(31) Hart abschaltbare Funktionen: Public-API und MCP (instanzweit,
Default aus, plus Zustimmung je Teich, I-39); SVG-Uploads
(upload.svgPolicy: reject); Dateiendungen per Allowlist; Caddy per
Compose-Profil. Nicht abschaltbar: lokale Authentisierung (I-02),
Plugins (I-19), Feeds (I-10).
4. Offene Punkte
Was in dieser Analyse nicht geklärt werden konnte, mit Grund:
- Jurisdiktion und Maintainer-Status der 366 Produktionspakete (I-27) — die Registry weist es nicht aus. Vorschlag: Kernpakete einzeln belegen, den Rest über den SBOM offenlegen.
- Funktionsfähigkeit ohne Internetzugang (I-28) — nur durch den Testlauf in netzisolierter Umgebung beantwortbar. Der Testlauf ist deshalb selbst ein Arbeitspaket (#220), kein Nachweisdokument.
- Sofortige Wirksamkeit künftiger Sicherheitsschalter (I-29) —
hängt davon ab, ob
plugins.enabledund ein Auth-Schalter am in-process-Cache vorbei gelesen werden. Zu entscheiden in #200/#216. - Vollständigkeit der Ausgabekanal-Liste (I-30) — durch Codelesen erstellt, nicht mechanisch belegt. Empfehlung: über den vorhandenen Route-Enumeration-Test absichern.
- Wirkung von
PRE_RESTORE-Snapshots und Backups auf ein Löschverlangen — beides sind gewollte Kopien mit eigener Retention; wie lange ein gelöschter eingestufter Inhalt darin fortlebt, ist eine Frage der Retention-Konfiguration beim Betreiber, nicht des Codes. Für das Löschkonzept (#229) muss die Zahl trotzdem benannt werden. - Werden Suchsnippets aus dem Klartext-Cache oder aus dem
tsvectorerzeugt? Für die Kennzeichnungspflicht (#211) reicht der Befund „Snippets enthalten Seitentext"; für die Frage, ob ein Treffer Inhaltsfragmente an nicht-lesende Nutzer ausgeben könnte, wäre eine genauere Betrachtung vonpostgres-search.provider.tsnötig. Die Berechtigungsprüfung joint auf lebende, sichtbare Seiten — ein Leck ist nicht ersichtlich, aber nicht abschließend geprüft.
5. Verhältnis zum Maßnahmenplan
Diese Ist-Aufnahme wurde nach 20-massnahmenplan.md erstellt. Sie
bestätigt dessen Befunde bis auf drei Korrekturen und liefert vier
Ergänzungen, die im Plan fehlen:
Bestätigt: alle Phase-1- und Phase-2-Positionen des Plans, jeweils mit Fundort (Tabelle in Abschnitt 2).
Korrigiert:
deploy/compose/.envist nicht im Repo (.gitignore:5–7,git ls-files) — der Punkt bleibt sinnvoll, aber als Verifikation statt als Leck-Behebung.- Das drawio-Plugin lädt keine externe URL (I-40) — kein Ausschlusskriterium.
- Labels fließen nicht in Exporte (I-03a) — die offene Frage des Plans ist mit Nein beantwortet, und der MCP-Gate divergiert nicht (I-33).
Ergänzt (im Plan nicht enthalten):
| Befund | Warum es zählt |
|---|---|
I-22 conversion_jobs halten Rohdokument-Bytes unbefristet |
Direkt löschkonzeptrelevant: eine gelöschte eingestufte Seite lebt in ihrem letzten Export weiter. |
I-23 mail_outbox ohne Retention, mit Seitentiteln |
Zweite unbefristete Kopie inhaltsnaher Daten. |
I-24 page_links.target_slug überlebt den Purge |
Der Slug einer eingestuften Seite kann selbst schützenswert sein. |
| I-25 IndexedDB-Kopie auf Endgeräten | Inhalte verlassen den Server auf Endgeräte-Datenträger — muss in der Abgrenzungserklärung als Betreiberpflicht (Endgeräteverschlüsselung) auftauchen. |
| I-26 Node-Version nicht gepinnt | Blockiert die Reproduzierbarkeitsaussage in #219. |
Empfehlung: I-22 bis I-26 als fünf zusätzliche Issues in die Meilensteine
M24 — VS-NfD: security quick wins (I-22, I-23, I-24, I-26) bzw. in die
Dokumentation M30 (I-25) aufnehmen. Das hebt den Umfang von 45 auf
50 Issues; Aufwand zusätzlich ca. 4–6 AT (I-22 M, I-23 S, I-24 S,
I-25 in #226/#231 enthalten, I-26 S).