dorfteich/docs/architecture/security.md
Claude Fable 5 db4c5ce9ca
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m53s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m37s
CI / Import/export fidelity gate (push) Successful in 52s
#190: configurable session lifetime with a server-side idle timeout
SESSION_ABSOLUTE_HOURS (default 168 h) caps a session's total lifetime
from login: expiresAt is set once at creation and never extended — the
old sliding 30-day renewal is gone. SESSION_IDLE_HOURS (default 72 h)
ends sessions unused for that long, enforced server-side against
lastSeenAt with a write throttle scaled to the idle bound so short idle
windows still renew. Expired rows are removed on validation and the
session list applies both bounds, so idle-dead sessions never show as
active. The cookie maxAge follows the configured absolute bound.

Documented in .env.example (with the VS-NfD reference values for the
upcoming hardening guide #227), compose passes the variables through,
security.md and ADR 0007 record the amendment.

Refs #190

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 11:11:15 +02:00

6.1 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 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.
  • 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".

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.

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.

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).