Instrument every full-content read channel for pages with classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read rendering, public JSON content, no-JS shell, expanded embeds, public API GET (incl. the MCP read_page path and write echoes), attachment download under the #212 effective classification, all export shapes (markdown, pond ZIP, account data export, queued docx/odt/pdf at enqueue), and collab-token issuance as the api-side proxy for the WS join. Events land in the new read_events table (no FKs — evidence survives page purges and hard user deletions) with actor, session key (session:/token:/job:/anon), page, pond, channel and the classification at read time. Recording failures are NOT swallowed: a failed write aborts the read (hard failure, the deliberate contrast to AuditService — decision recorded in ADR 0023 and security.md §Logging, together with the recorded residuals: content fragments and feeds). One e2e test per channel proves both the event and its absence for unclassified pages, plus the hard-failure semantics. Refs #222. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
17 KiB
Security concept
Threat-driven summary; detailed mechanics live in the referenced ADRs.
Assets & main threats
Wiki content (possibly confidential per pond/label), user credentials and e-mail addresses, instance availability. Threat actors: anonymous internet (public instance with self-signup), malicious registered users, malicious or sloppy plugin authors, compromised dependencies.
Authentication & session security (ADR 0007)
- Argon2id password hashing; opaque server-side sessions in HttpOnly, Secure, SameSite=Lax cookies; CSRF protected by SameSite + origin checks on mutating requests (double-submit token for the file-download edge cases).
- The origin check fails closed (issue #189): a cookie-carrying
mutation without
OriginandReferer(or with an unparsable one) is rejected with403 csrf_origin_mismatch. Non-browser clients authenticate with a PAT/bearer token and no cookie, which never reaches the check — the exception is structural, not a header loophole; a request that does carry the session cookie is always checked. Scripted cookie clients must sendOrigin: <APP_BASE_URL>. - Session bounds are configurable (issue #190): an absolute lifetime
(
SESSION_ABSOLUTE_HOURS, default 7 days, never extended by activity — also the cookiemaxAge) and an idle timeout (SESSION_IDLE_HOURS, default 3 days), both enforced server-side, the idle bound againstlastSeenAt. - E-mail verification (double opt-in) before an account can create content; password reset via single-use hashed tokens; both rate-limited.
- Rate limiting (DB-backed) on login, signup, reset, and API; lockout backoff on repeated failed logins per account+IP.
- Feed tokens (issue #149) authenticate feed URLs via
?token=— feed readers cannot send headers, which is why the credential lives in the URL at all. Moving it into a path segment was rejected (issue #191): a path lands in the same proxy and request logs as a query string. Instead: the instance switchfeeds.enabledhides the whole feed surface with 404 semantics (the VS-NfD reference configuration turns feeds off), tokens are stored hashed, and the api's request log masks?token=values (common/mask-token-param.ts), so no code path logs the credential. - Self-registration can be disabled instance-wide; personal-pond quotas (editors/readers/ponds/storage) bound the blast radius of spam accounts.
Authorization
- Single resolution algorithm (
permissions.md) inpackages/shared, enforced in API guards and at collab token issuance — never in the client. - Default-closed: no grant → no access. Public access is always an explicit grant.
- Admin actions are audit-logged (
operations.md).
Content & upload security
- Editor content is structured (ProseMirror schema) — no raw HTML from
users. The HTML render endpoint escapes everything outside the schema;
link protocols allowlisted (
https,http,mailto). - Uploads (ADR 0011): MIME/extension allowlist, size limits, magic-byte
checks, SVG sanitization or rejection,
Content-Disposition: attachmentfor non-image types, no user content served same-origin as executable (X-Content-Type-Options: nosniff; uploads path never servestext/html). - App CSP (strict):
default-src 'self';font-src 'self'(ADR 0016); no third-party origins at all — the GDPR posture is "zero external requests". - Attachment integrity (issue #199): every upload stores the SHA-256
of its bytes, computed from the in-memory buffer as it is written (never
by re-reading disk). Every download re-hashes the stored object BEFORE
the first byte leaves and fails closed on mismatch with
attachment_integrity_failure(HTTP 500); the mismatch is recorded in the audit trail (file.integrity_failed). §52 VSA leaves detecting manipulation of the application's own payloads to the application — only it knows what the file should be. Operator response to a verification failure: treat the object as tampered/corrupt, restore the affected file from backup (restore runbook), then re-download to confirm; the audit entry carries both hashes for the report. Pre-existing rows are hashed by the nightly backfill (part of the orphan-file-sweep job) and served unverified only until it reaches them; unreadable files are logged and retried, never silently skipped. - The full-text index holds no trashed content (issue #195): trashing
a page or pond clears the affected
search_vectors, restore rebuilds them,reindexAllconverges to the same invariant, and a one-off migration backfilled pre-existing trash. The query-sidedeleted_at IS NULLjoins stay in place as the second, independent layer — a future query path that forgets them still finds no trashed vectors. (The plaintext cache row itself remains until purge; the index is the concern here because it is queryable.)
Security response headers & CORS (issue #197)
Every api response carries this header set, stamped by a hand-rolled
15-line middleware (apps/api/src/common/security-headers.middleware.ts)
rather than helmet — the set is small enough to own, each value is a
deliberate decision, and the api gains no transitive dependency. It is
wired through the AppModule's MiddlewareConsumer, so the e2e harness
boots the exact production middleware.
| Header | Value | Why |
|---|---|---|
Strict-Transport-Security |
max-age=31536000 |
One year, no includeSubDomains — the api cannot speak for sibling subdomains it does not control. Browsers ignore HSTS over plain http, so it is sent unconditionally. |
X-Content-Type-Options |
nosniff |
No MIME sniffing, anywhere (the uploads path relies on this too, see above). |
Referrer-Policy |
no-referrer |
Page paths are permission-scoped knowledge; leak them to no destination. |
X-Frame-Options |
SAMEORIGIN |
Deliberately not DENY: the plugin sandbox (ADR 0008) embeds /api/v1/plugins/<id>/<version>/frame same-origin, and the frame's CSP has no frame-ancestors — this header governs its framing. |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=(), usb=() |
Powerful browser features denied outright; nothing in the app uses them. |
CORS is a stated decision, not an implicit default: no foreign origin
is granted anything. The middleware echoes Access-Control-Allow-Origin
(plus Allow-Credentials: true) only for the APP_BASE_URL origin
itself — where browsers never consult CORS anyway, since the SPA calls
the api same-origin (the dev server proxies /api). The echo documents
the stance rather than enabling a caller; consequently there is no
preflight handling (same-origin requests never preflight), and every
response carries Vary: Origin for cache correctness. Cross-origin API
access is cookie-less by design anyway (PAT/Bearer, see Public API), and
non-browser clients are unaffected by CORS.
Regression fence: apps/api/src/common/security-headers.e2e.test.ts
(header set, foreign origin gets no ACAO) and the frame assertion in
plugins.e2e.db.test.ts. TLS termination itself is the reverse proxy's
job (out of scope, below).
Plugin sandboxing (ADR 0008, operational)
- Code plugins: opaque-origin iframes, no network (
connect-src 'none'), capability-scoped postMessage API executed with the viewer's permissions server-side; declared capabilities surfaced to the Site Admin at install time. - Style plugins: CSS sanitized (no
@import/externalurl()), scoped class names. - Install surface restricted to Site Admins; packages size-limited and
schema-validated; the
plugins/directory watcher only trusts the volume (host-level access implies game over anyway).
Collaboration layer
- WebSocket connect requires a short-lived (≤ 60 s) single-purpose JWT
bound to user + page + mode; write revocation closes sessions via
LISTEN/NOTIFY (
realtime-collaboration.md). - Update size and document size ceilings prevent resource-exhaustion via crafted CRDT updates.
Secrets & configuration
- Secrets (DB password, token root key, SMTP credentials) live only in
the stage
.env(mode 600, never in git) and container env — not in the database (instance_settingsstores non-secret config; the SMTP password entered in the setup wizard is written to the env-backed secret store, not to a DB row). - Token key hierarchy (ADR 0020, issue #188):
COLLAB_TOKEN_SECRETis a ROOT key. Each token purpose uses its own HKDF-SHA-256 subkey (deriveTokenKeyinpackages/shared/src/token-crypto.ts):collabfor the collaboration JWTs (signed and verified byjose, HS256 as an explicit allowlist),unsubscribefor the digest unsubscribe links. No code path signs with the root key directly, so a compromise of one purpose's tokens is not transferable to the other. - Dual-verify window: unsubscribe links minted before the key separation
live in already-sent mail (90-day TTL). Verification accepts the legacy
derivation (root key + purpose prefix) until 2026-11-01
(
LEGACY_VERIFY_UNTILinapps/api/src/notifications/unsubscribe-token.ts), after which the legacy path goes dead automatically. New tokens are only ever signed with the subkey. - Key rotation: rotating the root key rotates every derived subkey at once
(desired: one secret to rotate) via env change plus rolling restart;
procedure documented in
operations.mdrunbooks. - Dependencies: lockfile-pinned; monthly update batch; images pinned to digests in Prod.
Supply chain artefacts (issue #202)
- SBOMs: every release run (
release.yml) generates CycloneDX 1.6 SBOMs with a pinnedanchore/syftcontainer — one per released image (scanned from the freshly built image tar, OS packages included) and one for the pnpm workspace (scanned frompnpm-lock.yaml) — and attaches them as build artefacts namedsupply-chain-vX.Y.Zon the release's action run. Provenance: the workflow file records the exact syft version; regenerate any of them withdocker save <image> -o image.tar && docker run … anchore/syft:<pinned> scan docker-archive:/image.tar -o cyclonedx-jsonrespectivelyscan dir:<workspace> -o cyclonedx-jsonat the release tag. - License policy:
scripts/check-licenses.mjsholds the documented allowlist (permissive licenses, plus MPL-2.0 and CC-BY-4.0 with recorded reasoning, plus a per-package exception table for wrong upstream metadata). CI runs the gate on every pull request; the release run additionally stores the fullpnpm licensesreport next to the SBOMs. A dependency outside the allowlist fails the build — extending the policy is a reviewed change to that script, never a build fix.
Logging
- Application logs are pino JSON on stdout;
authorizationandcookieheaders are redacted, request bodies are never logged, and feed-token query values are masked (issue #191). Log forwarding and retention are the container runtime's job (SIEM division of labour — the application side of that contract is the stable event catalogue, issue #201). - Audit event catalogue (issue #201): the versioned contract SIEM
rules are written against — every emittable event id with trigger,
severity, actor/target semantics and fields — lives in
docs/architecture/audit-events.md. The action set is a typed union in code (an uncatalogued id cannot be emitted), audit stdout lines carry the catalogueseverity, and a CI fence (audit-catalogue.test.ts) fails when document and code drift. Forwarding path: container stdout → the operator's collector; deliberately no application-side syslog client. - The persistent audit trail (
audit_log, issue #86) records auth and admin events — who changed access or configuration, not who edited what; content activity stays log-only by design. - Audit retention (issue #196): entries are kept for
audit.retentionDays(instance setting, default 365) and pruned by the dailyaudit-retentionjob; each pruning run is itself recorded asaudit.prunedwith count and cutoff, so a gap in the trail is always explainable. The read-access trail (#222–#225) is deliberately not covered by this period — it gets its own. - Read-access trail (issue #222, ADR 0023): reads of pages with
classification = vs_nfdland asread_eventsrows — only classified pages, which is what keeps the purpose limitation defensible (variant A). The instrumented channels, and the emission point of each:page_view— authenticated SPA state fetch (GET /pages/:id, the by-slug variant), the rendered read view (/read/...), the public JSON content route, the plugin-API content route, and an expanded embed of a classified page inside another page's rendering.no_js_shell— the server-rendered/public/:pond/:pagedocument.public_api—GET /api/public/v1/.../pages/:slugand the MCPread_pagetool (same emission point); the write echo of the public API's create/update counts as a read of the returned page.attachment—GET /media/:fileIdwhen the attachment's effective classification (#212 semantics) isvs_nfd.export— per-page markdown download, one event per classified page in a pond ZIP or the account data export, and the queued.docx/.odt/.pdfexport (recorded at enqueue — the user's action; the worker's conversion is machinery, not a second read).collab_join— collab-token issuance, the api-side proxy for the collab WS join: the collab server has no permission context, and the 60 s token TTL yields per-minute granularity for live sessions. Each event carries timestamp, actor (or the documentedanonmarker), session key (session:/token:/job:/anon), page, pond, channel and the classification at read time (a later reclassification never rewrites history). Failure is not silent: a failed trail write aborts the read with a 500 — the deliberate contrast to the audit trail's swallow-and-log, because a lost event is a gap in evidence (ADR 0023). Deliberately NOT instrumented (recorded residual): content fragments — search-result snippets, task-overview rows, backlink titles — and the Atom feeds (disabled in the VS-NfD reference configuration, #227). Digest mails carry titles only (the #231 residue).
Privacy (GDPR)
- No external requests from the browser (fonts self-hosted, no CDNs, no analytics by default).
- Instance-configurable legal pages (imprint, privacy policy) are a core feature; dorfteich.online uses the operator's standard texts.
- Data minimization: username, e-mail, password hash, locale — nothing else required. Account deletion: personal pond and authored ponds follow the trash/purge path; authorship on shared content is pseudonymized ("deleted user"). A data-export endpoint (own profile + own ponds as Markdown/ZIP) supports access/portability requests.
- IP addresses appear only in rate-limit counters (short TTL) and reverse proxy logs (host-level rotation) — documented in the privacy-policy template.
- Sent mail is not kept forever (issue #234): SENT and permanently FAILED
mail_outboxrows are deleted aftermail.outboxRetentionDays(default 30) by a daily job. This bounds the copy of content-adjacent data — digest bodies name page titles and actors. That digest mails carry page titles at all is a recorded, accepted residue (issue #231): there is no per-page classification marking yet to key a suppression on (that lands with ADR 0022 / M32, revisit there), and a VS-NfD reference configuration (#227) can leave SMTP unconfigured entirely.
Out of scope (v1, explicit)
- No end-to-end encryption of page content (server sees plaintext — needed for search, export, rendering).
- No plugin marketplace/signing — installation is a deliberate Site Admin act of trust in the reviewed package.
- No SSO in MVP (OIDC-ready per ADR 0007).