Skip to content

fix(server): enforce app-wide browser security headers (#1541) - #1995

Draft
serenakeyitan wants to merge 2 commits into
mainfrom
fix/sec-041-app-security-headers
Draft

fix(server): enforce app-wide browser security headers (#1541)#1995
serenakeyitan wants to merge 2 commits into
mainfrom
fix/sec-041-app-security-headers

Conversation

@serenakeyitan

Copy link
Copy Markdown
Contributor

Closes #1541 (audit finding SEC-041).

What

Every HTTP response the server produces now carries an app-wide browser security header set, stamped by a single Fastify onSend hook registered in buildApp:

Header Value
Content-Security-Policy enforced least-privilege policy (details below)
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=()
X-Frame-Options DENY (plus CSP frame-ancestors 'none')

The onSend placement means the layer reaches every reply shape: SPA shell, SPA deep-link fallback, API JSON, API 404s, asset 404s, and error-handler replies.

CSP design

  • Code owns the policy shape; config owns only origin allowlists. Keyword sources ('self', 'none', data:, blob:) are hardcoded in buildContentSecurityPolicy; the shared config schema validates each configured entry as an https/wss origin (wildcard subdomains allowed) and rejects keyword/scheme smuggling like 'unsafe-inline' or javascript:, so env vars can widen the allowlist but never weaken the policy.
  • script-src 'self' <origins> with no unsafe-inline, no unsafe-eval, no nonces. Made viable by externalizing both index.html inline bootstraps into public/theme-init.js (sync, pre-paint dark mode preserved) and public/analytics-init.js (deferred GA4 + Clarity, still hostname-gated to production).
  • connect-src includes a ws(s)://<host> source derived from server.publicUrl for the live-update WebSocket; analytics/Sentry ingest origins come from the allowlist.
  • img-src 'self' data: blob: <avatar/GA hosts> — uploads/previews keep working; arbitrary remote images in chat markdown are now intentionally blocked (the one deliberate behavior change; documented in the QA case).
  • Kill switch: FIRST_TREE_SECURITY_HEADERS_ENABLED=false removes the whole layer (logged warning).

Config surface

New security group in shared server config, documented in docs/cli-reference.md:

  • FIRST_TREE_SECURITY_HEADERS_ENABLED (default true)
  • FIRST_TREE_CSP_SCRIPT_ORIGINS / FIRST_TREE_CSP_CONNECT_ORIGINS / FIRST_TREE_CSP_IMG_ORIGINS — comma/whitespace-separated origin lists that replace the defaults (defaults = current production dependencies: GA4, Clarity, Sentry ingest, GitHub/Google avatar hosts).

Tests

  • packages/server/src/__tests__/security-headers.test.ts — unit tests pin the exact header set and every CSP directive (incl. never-unsafe-inline/eval and publicUrl → ws derivation); integration tests via buildApp prove all five reply shapes carry the full set and that the kill switch removes it.
  • packages/shared/src/config/__tests__/server-config.test.ts — defaults, env list parsing (trim/lowercase/replace semantics), and rejection of non-origin sources.
  • packages/web/src/__tests__/csp.test.ts — regression net: index.html must contain zero inline scripts, referenced bootstraps must exist in public/, and every third-party loader origin in analytics-init.js must be in the default script-src allowlist.
  • packages/web/src/__tests__/analytics.test.ts — updated to assert against the externalized analytics-init.js.

Verification

  • pnpm check PASS, pnpm typecheck PASS (all 10 tasks)
  • Server suite: 245 files / 2654 tests PASS (full run against local PostgreSQL via the suite's CI_DATABASE_URL path)
  • Shared suite: 700/700 PASS; web: targeted CSP/analytics tests PASS (4 unrelated timezone-sensitive failures reproduce identically on clean main)

QA

Added packages/qa/cases/cross-surface/web-security-headers-csp.md — a formal QA pass with a live browser under the enforced policy is recommended before release (devtools-console CSP-violation watch during an authenticated pass; product tests can't see that layer).

Every HTTP response (SPA shell, static assets, API JSON, 404s, error
bodies) now carries a code-owned security header set via a Fastify
onSend hook: enforced least-privilege CSP (script-src without
unsafe-inline/unsafe-eval, frame-ancestors 'none'), HSTS (1y,
includeSubDomains), X-Content-Type-Options: nosniff, Referrer-Policy:
strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geo/
payment off), and X-Frame-Options: DENY.

The header shape is code-owned in packages/server/src/security-headers.ts;
only third-party CSP origin allowlists are configurable per environment
(FIRST_TREE_CSP_SCRIPT_ORIGINS / _CONNECT_ORIGINS / _IMG_ORIGINS in the
shared server config), with defaults matching the production web
bundle's real dependencies (GA4, Clarity, Sentry ingest, GitHub/Google
avatar hosts). The schema rejects CSP keyword sources so an env var can
never smuggle unsafe-inline into the policy.

index.html inline bootstraps (GA4/Clarity, theme) moved to external
same-origin files (public/analytics-init.js, public/theme-init.js) so
script-src 'self' holds without nonce plumbing; csp.test.ts pins the
zero-inline-script layout.
@serenakeyitan serenakeyitan added the fire_wip GoF: draft PR in progress label Jul 23, 2026
Comment thread packages/web/src/__tests__/csp.test.ts Fixed
CodeQL flagged the script-scan regex (js/bad-tag-filter): `</script>`
did not match end-tag variants like `</script >`, so an inline script
closed that way could slip past the CSP regression net. Use happy-dom's
DOMParser — a real HTML parser — so tag matching follows the same rules
browsers apply, and drop the secondary attribute regex too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@serenakeyitan serenakeyitan added fire_submitted GoF: PR submitted for maintainer review (stays draft) and removed fire_wip GoF: draft PR in progress labels Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fire_submitted GoF: PR submitted for maintainer review (stays draft)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add app-wide browser security headers for Fastify/SPA

2 participants