# 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** 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: 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:562–577`; kein `oidc`-Treffer in `apps/api/src` | `UserIdentity.provider` ist als `"password"` heute / `"oidc:"` 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//`. 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-`, 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: 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:318–347`) 4. `page_content_cache` — `plain_text`, `markdown`, `html`, `outline` (jsonb) — vier abgeleitete **Klartext**-Formen, bei jedem Save erneuert (`schema.prisma:392–411`) 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//` (`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:** 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: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–105` 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: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: 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:5–7`, `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. **4–6 AT** (I-22 M, I-23 S, I-24 S, I-25 in #226/#231 enthalten, I-26 S).