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

127 lines
6.1 KiB
Markdown

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