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
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
468 lines
61 KiB
Markdown
468 lines
61 KiB
Markdown
# 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:<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:
|
||
|
||
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/<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:**
|
||
|
||
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).
|