dorfteich/docs/architecture/security.md
Claude Fable 5 1f56f34113
All checks were successful
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
Release / Build release images and notes (push) Successful in 3m31s
Release / Release-candidate operations QA (push) Successful in 46s
CI / Build container images (push) Has been skipped
Prod deploy / Deploy the released images to Prod (push) Successful in 58s
CI / Import/export fidelity gate (push) Successful in 59s
CI / Lint, typecheck, test (push) Successful in 6m40s
CI / Auth e2e pack (push) Successful in 8m21s
Restore drill / Restore the latest backup into a scratch stack (push) Successful in 1m18s
CI / Build container images (pull_request) Successful in 2m53s
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
#296: remove the unsubscribe-token dual-verify window early
Operator decision at the ADR 0020 acceptance: verification is
subkey-only now instead of waiting for the stated 2026-11-01 expiry.
Links in digest mails sent before the #188 key separation stop working;
recipients use the in-app notification settings. A regression test pins
that the legacy derivation (root key + purpose prefix) can never verify
again; security.md records the removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 23:03:03 +02:00

22 KiB
Raw Blame History

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 Origin and Referer (or with an unparsable one) is rejected with 403 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 send Origin: <APP_BASE_URL>.
  • Session bounds are configurable (issue #190): an absolute lifetime (SESSION_ABSOLUTE_HOURS, default 7 days, never extended by activity — also the cookie maxAge) and an idle timeout (SESSION_IDLE_HOURS, default 3 days), both enforced server-side, the idle bound against lastSeenAt.
  • 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 switch feeds.enabled hides 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.

External authentication (OIDC, issue #214, ADR 0021)

  • Authorization Code + PKCE, discovery-configured, ID tokens validated against the IdP's JWKS with an explicit RS256/ES256 allowlist — built on jose (the vetted library from #188) plus fetch, so no new dependency enters the supply chain for a security base function. Nothing is IdP-specific; Keycloak is the reference IdP.
  • Deploy-level configuration (who authenticates users is a platform decision, not a Site-Admin setting): OIDC_ISSUER, OIDC_CLIENT_ID, optional OIDC_CLIENT_SECRET (public client uses PKCE alone), OIDC_SCOPES (default openid profile email), OIDC_PROVIDER_LABEL (login-button text). Enabled iff issuer + client id are set; the login page discovers this via GET /auth/methods.
  • State, nonce and the PKCE verifier travel in a signed, HttpOnly, SameSite=Lax cookie (10 min TTL) whose key is HKDF-derived for the dedicated oidc-state purpose (ADR 0020) — the callback binds the IdP's state and the ID token's nonce to exactly that browser.
  • Identities: provider = "oidc:<issuer>", subject from the token. First login creates the account just-in-time (ACTIVE, mail verified — refused if the IdP does not supply a verified address). An existing local account with the same address is NEVER adopted silently (that is an account-takeover path): the login is refused with oidc_link_required, and the owner links explicitly via GET /auth/oidc/link from a logged-in session (audited as auth.identity_linked).
  • One session mechanism: OIDC produces the same server-side session as the password login (#190 bounds apply). IdP-initiated single logout is deliberately NOT implemented: sessions are short-bounded, and the claim-mapping revocation path (#217) plus the account-disable flag cover the leaver case — recorded in ADR 0021.
  • The hard local-auth switch (issue #216): AUTH_LOCAL_ENABLED=false closes every local credential flow with 404 — login, signup, e-mail verification, resend, password forgot/reset/change — enforced centrally in the auth guard via a route marker with an enumeration fence. Deploy-level on purpose (a compromised Site Admin cannot flip it back). Sessions, logout and PAT/feed-token issuance for externally authenticated users keep working; stored password hashes remain (documented, ADR 0021). Bootstrap: complete setup before flipping.
  • Trusted-proxy / mTLS path (issue #215) — for perimeters that authenticate before the application. The trust boundary, precisely: the identity header (AUTH_PROXY_HEADER) is honoured if and only if the request's TCP peer address — never a forwarded header — is on AUTH_PROXY_TRUSTED_PEERS. Off unless both are set; nothing about the header is ever guessed. A request carrying the header from any other peer is rejected outright (403) and audited (auth.proxy_rejected) — that is a spoof attempt, not a misconfiguration. A session cookie riding alongside the header never escalates beyond the header identity; with the feature off the header is inert. Mapping is explicit (AUTH_PROXY_MAP: the value is the local username or e-mail; no just-in-time creation — the header carries no verified address). The mTLS variant (AUTH_PROXY_MODE=mtls-dn) expects the TLS terminator to forward the client-certificate subject DN in the same header and maps the configured attribute (AUTH_PROXY_DN_ATTRIBUTE, default CN). Everything upstream of the trusted peers — TLS termination, certificate validation, header hygiene (the proxy MUST strip the header from incoming traffic) — is the operator's platform responsibility.
  • Keycloak verification procedure (repeatable): run docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev; via kcadm.sh: create realm dorfteich, a public client dorfteich-web with redirect URI <APP_BASE_URL>/api/v1/auth/oidc/callback, and a user with password + verified mail. Start the api with the OIDC variables pointing at http://localhost:8089/realms/dorfteich, then drive GET /auth/oidc/login → Keycloak form login → callback with a cookie jar (curl suffices) and confirm GET /auth/me returns the just-in-time account. Last verified 2026-07-31 against Keycloak 26.0.

Authorization

  • Single resolution algorithm (permissions.md) in packages/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: attachment for non-image types, no user content served same-origin as executable (X-Content-Type-Options: nosniff; uploads path never serves text/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, reindexAll converges to the same invariant, and a one-off migration backfilled pre-existing trash. The query-side deleted_at IS NULL joins 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/external url()), 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_settings stores 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_SECRET is a ROOT key. Each token purpose uses its own HKDF-SHA-256 subkey (deriveTokenKey in packages/shared/src/token-crypto.ts): collab for the collaboration JWTs (signed and verified by jose, HS256 as an explicit allowlist), unsubscribe for 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: REMOVED early by operator decision at the ADR 0020 acceptance (issue #296, 2026-07-31). Verification is subkey-only; unsubscribe links minted before the key separation no longer work — recipients use the in-app notification settings instead. A regression test pins that the legacy derivation (root key + purpose prefix) can never verify again.
  • 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.md runbooks.
  • 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 pinned anchore/syft container — one per released image (scanned from the freshly built image tar, OS packages included) and one for the pnpm workspace (scanned from pnpm-lock.yaml) — and attaches them as build artefacts named supply-chain-vX.Y.Z on the release's action run. Provenance: the workflow file records the exact syft version; regenerate any of them with docker save <image> -o image.tar && docker run … anchore/syft:<pinned> scan docker-archive:/image.tar -o cyclonedx-json respectively scan dir:<workspace> -o cyclonedx-json at the release tag.
  • License policy: scripts/check-licenses.mjs holds 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 full pnpm licenses report 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; authorization and cookie headers 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 catalogue severity, 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 daily audit-retention job; each pruning run is itself recorded as audit.pruned with 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_nfd land as read_events rows — only classified pages, which is what keeps the purpose limitation defensible (variant A). Master switch readTrail.enabled, default off (#225): off means no event is written anywhere, including stdout, and the api announces the switch position once per boot so an eventless trail is never ambiguous. The written purpose limitation lives in docs/vs-nfd/60-sicherheitsdokumentation.md §7. 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/:page document.
    • public_apiGET /api/public/v1/.../pages/:slug and the MCP read_page tool (same emission point); the write echo of the public API's create/update counts as a read of the returned page.
    • attachmentGET /media/:fileId when the attachment's effective classification (#212 semantics) is vs_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/.pdf export (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 documented anon marker), 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_outbox rows are deleted after mail.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).