dorfteich/docs/vs-nfd/10-ist-aufnahme.md
Claude Opus 5 fd07f716f6
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts)
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
2026-07-30 01:48:05 +02:00

61 KiB
Raw Blame History

VS-NfD-Ist-Aufnahme Dorfteich

Auftrag: docs/vs-nfd/00-analyse-auftrag.md. Read-only-Analyse — keine Codeänderungen. Stand: 2026-07-29, Code-Stand main = 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 23 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:

  1. 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.
  2. 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.
  3. 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:8 ist 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:562577; 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:263301 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:7679, 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:108112 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:530538; 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,142143 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:7491; 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:541559; 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:746784; 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:683697; 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:2544: 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:441461 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,134141,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:710 "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,925 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:232253 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:4041 (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:1220 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:8493; 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:6270; apps/collab/src/server.ts:108125; 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:121144; 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:562577). Gehasht wird mit argon2id über die argon2-Bibliothek inklusive needsRehash (users/password.ts:925) — 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:580595, 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:5258). 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:925
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:102108), die Suche joint auf lebende Seiten und Teiche (postgres-search.provider.ts:142143), 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:

  1. pages.ydoc_state (Bytes) — das lebende Yjs-Dokument
  2. page_updates — das Update-Log; gemergt erst bei Compaction
  3. page_versions.ydoc_snapshot — vollständige, selbstständige Snapshots (AUTO/MANUAL/PRE_RESTORE), damit ein Restore nie vom Update-Log abhängt (schema.prisma:318347)
  4. page_content_cacheplain_text, markdown, html, outline (jsonb) — vier abgeleitete Klartext-Formen, bei jedem Save erneuert (schema.prisma:392411)
  5. page_content_cache.search_vector — der gewichtete tsvector

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:746784 unbefristet außer für Datenexport-Jobs (I-22)
mail_outbox.text_body / .html_body — Digest-Mails mit Seitentiteln schema.prisma:683697 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:100164 an Seite/Nutzer gekoppelt, kaskadierend
Backups apps/backup/, ADR 0015 Retention konfigurierbar
PWA-Service-Worker vite.config.ts:1220 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:

  1. page_links-Zeilen fremder Seiten behalten den target_slug der gelöschten Seite, to_page_id wird genullt (I-24).
  2. conversion_jobs behalten die Rohbytes jedes Exports dieser Seite (I-22).
  3. mail_outbox behält Digest-Mails mit dem Seitentitel (I-23).
  4. 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):

  • Label ist teich-gebunden (pondId, schema.prisma:423437) — 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:98105 lädt labelIds ausschließlich für permissions.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
PDF import-export/pdf-html.ts:7679 (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:

  1. 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.
  2. Funktionsfähigkeit ohne Internetzugang (I-28) — nur durch den Testlauf in netzisolierter Umgebung beantwortbar. Der Testlauf ist deshalb selbst ein Arbeitspaket (#220), kein Nachweisdokument.
  3. Sofortige Wirksamkeit künftiger Sicherheitsschalter (I-29) — hängt davon ab, ob plugins.enabled und ein Auth-Schalter am in-process-Cache vorbei gelesen werden. Zu entscheiden in #200/#216.
  4. Vollständigkeit der Ausgabekanal-Liste (I-30) — durch Codelesen erstellt, nicht mechanisch belegt. Empfehlung: über den vorhandenen Route-Enumeration-Test absichern.
  5. 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.
  6. Werden Suchsnippets aus dem Klartext-Cache oder aus dem tsvector erzeugt? 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 von postgres-search.provider.ts nö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:

  1. deploy/compose/.env ist nicht im Repo (.gitignore:57, git ls-files) — der Punkt bleibt sinnvoll, aber als Verifikation statt als Leck-Behebung.
  2. Das drawio-Plugin lädt keine externe URL (I-40) — kein Ausschlusskriterium.
  3. 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. 46 AT (I-22 M, I-23 S, I-24 S, I-25 in #226/#231 enthalten, I-26 S).