dorfteich/docs/architecture/security.md
Claude Fable 5 3c62b7b773
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m15s
CI / Build container images (pull_request) Successful in 1m9s
CI / Auth e2e pack (pull_request) Successful in 7m43s
CI / Import/export fidelity gate (pull_request) Successful in 56s
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 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Lint, typecheck, test (push) Successful in 5m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m35s
CI / Import/export fidelity gate (push) Successful in 55s
#197: security response headers and an explicitly restrictive CORS policy
Hand-rolled middleware instead of helmet: the header set is small enough
to own, every value is a deliberate decision, and the api gains no
transitive dependency. HSTS (no includeSubDomains — the api cannot speak
for sibling subdomains), nosniff, Referrer-Policy no-referrer,
X-Frame-Options SAMEORIGIN (not DENY: the plugin sandbox frame embeds
same-origin and its CSP has no frame-ancestors, so this header governs),
and a minimal deny-all Permissions-Policy.

CORS grants no foreign origin anything; only the APP_BASE_URL origin is
ever echoed (where browsers do not consult CORS anyway), with
Vary: Origin on every response. No preflight handling — same-origin
requests never preflight, and cross-origin API access is cookie-less by
design (PAT/Bearer).

Wired via the AppModule MiddlewareConsumer so createTestApp boots the
identical middleware. Fences: security-headers.e2e.test.ts (header set,
foreign origin gets no ACAO) and a frame assertion in
plugins.e2e.db.test.ts (framing stays possible). Rationale table in
security.md.

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

194 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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