All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m5s
CI / Build container images (pull_request) Successful in 2m48s
CI / Auth e2e pack (pull_request) Successful in 7m50s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m11s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
audit.retentionDays (instance setting, default 365) bounds the audit_log: the daily audit-retention job deletes entries past the period and records the deletion itself (audit.pruned with count, cutoff and period) so a gap in the trail is always explainable. Lives in its own AuditRetentionService because the settings service audits its writes - folding retention into AuditService would close a constructor cycle. The read-access trail (#222-#225) is deliberately not covered; it gets its own period. security.md gains the Logging section the schema has cited for a while; the maintenance-job fence moves 6 -> 7 (the deliberate new row). Refs #196 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
161 lines
8.2 KiB
Markdown
161 lines
8.2 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.
|
||
- 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.)
|
||
|
||
## 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).
|