# 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: `. - 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. ## 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". - The full-text index holds **no trashed content** (issue #195): trashing a page or pond clears the affected `search_vector`s, 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///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: 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_UNTIL` in `apps/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.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 -o image.tar && docker run … anchore/syft: scan docker-archive:/image.tar -o cyclonedx-json` respectively `scan dir: -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). - 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. ## 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).