diff --git a/.env.example b/.env.example index 88f7154..d4a783b 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,11 @@ SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../..." ALERT_THRESHOLD="5" # post spike alert when last-hour events >= this WINDOW_MINUTES="60" # how many minutes back to aggregate +# Content-Security-Policy (audit O5). The CSP ships Report-Only by default so it +# can never break the site. After confirming zero violations in staging, set this +# to "true" to promote it to an enforcing Content-Security-Policy header. +CSP_ENFORCE="false" + # Optional NEXT_PUBLIC_APP_URL="http://localhost:3000" NODE_ENV="development" diff --git a/SESSION-HANDOVER.md b/SESSION-HANDOVER.md index 55eaf0a..90a20a7 100644 --- a/SESSION-HANDOVER.md +++ b/SESSION-HANDOVER.md @@ -182,7 +182,7 @@ All Sprint 11 additions are listed in `.env.example`. ### Post-launch (Sprint 13 candidates) 1. ~~PayMongo HMAC verification~~ — stale, done (see above) -2. CSP header (deferred from STORY-055) +2. ~~CSP header (deferred from STORY-055)~~ — **shipped Report-Only** in `next.config.ts` (audit O5). Set `CSP_ENFORCE=true` in the deploy env to promote to an enforcing header once staging shows zero violations. Wiring a `report-uri`/`report-to` sink (e.g. Sentry) is the remaining follow-up. 3. BottomNav on lesson/quiz pages (S9 carry-over) 4. Verify Resend webhook secret env var set in Vercel prod (STORY-055 finding #3 / S12 audit) 5. Audit follow-ups O1–O6 in `docs/security/code-audit-2026-07-15.md` (field renames, async scrypt, distributed rate limiting, email-verification flow, CSP, Blob receipt storage) diff --git a/docs/security/code-audit-2026-07-15.md b/docs/security/code-audit-2026-07-15.md index 9a43053..03cf908 100644 --- a/docs/security/code-audit-2026-07-15.md +++ b/docs/security/code-audit-2026-07-15.md @@ -198,7 +198,7 @@ re-verifies the cookie). **Resolution:** removed. | O2 | Async `scrypt` (event-loop) | Sprint 13 | | O3 | Distributed rate limiting (Upstash/KV) for multi-instance prod | Sprint 13 | | O4 | Real email-verification flow (send + verify endpoint + backfill) | Sprint 13 | -| O5 | CSP header (pre-existing S13-001) | Sprint 13 | +| O5 | CSP header (pre-existing S13-001) — **shipped Report-Only** in `next.config.ts`; promote to enforcing via `CSP_ENFORCE=true` after staging shows zero violations | Report-Only done; enforce pending staging | | O6 | Durable receipt storage on private Vercel Blob | Sprint 13 | | O7 | Verify CI's `migrate deploy` step actually ran (was green with SQLite lock?). Migration history now regenerated for Postgres (2026-07-16) — re-run CI to confirm. | Now | | O8 | Update `security-audit-2026-07-13.md` + `SESSION-HANDOVER.md` — PayMongo HMAC is done | Now | diff --git a/next.config.ts b/next.config.ts index e6679a5..9e26d8b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,62 @@ import type { NextConfig } from 'next'; import { withSentryConfig } from '@sentry/nextjs'; +// Content-Security-Policy (audit O5 / S13-001). +// +// Rollout is intentionally two-phase. This header ships in **Report-Only** mode +// by default so it can never break the live site: browsers evaluate the policy +// and report violations (to the console / any configured sink) but still render +// everything. Once staging has been observed to produce zero violations, set +// `CSP_ENFORCE=true` in the deploy environment to promote it to an enforcing +// `Content-Security-Policy` header — no code change required. +// +// Notes on the allow-list (the "not-yet-finalized" concern from the deferral): +// - script-src / style-src need 'unsafe-inline': Next's App Router injects +// inline hydration scripts, and 19 components use inline `style={{…}}`. +// 'unsafe-eval' is added in dev only (React Fast Refresh needs it). +// - img-src is permissive (https:) because lesson MDX is rendered via +// dangerouslySetInnerHTML and may reference images from anywhere. +// - connect-src covers Sentry ingest (direct + the /monitoring tunnel is +// same-origin already) and Vercel Blob. +// - frame-ancestors 'none' mirrors the existing X-Frame-Options: DENY. +function contentSecurityPolicy(): string { + const isProd = process.env.NODE_ENV === 'production'; + const scriptSrc = ["'self'", "'unsafe-inline'"]; + if (!isProd) scriptSrc.push("'unsafe-eval'"); + + const directives: Record = { + 'default-src': ["'self'"], + 'script-src': scriptSrc, + 'style-src': ["'self'", "'unsafe-inline'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:'], + 'font-src': ["'self'", 'data:'], + 'connect-src': [ + "'self'", + 'https://*.sentry.io', + 'https://*.ingest.sentry.io', + 'https://*.blob.vercel-storage.com', + ], + 'frame-ancestors': ["'none'"], + 'base-uri': ["'self'"], + 'form-action': ["'self'"], + 'object-src': ["'none'"], + }; + + const parts = Object.entries(directives).map( + ([name, values]) => `${name} ${values.join(' ')}`, + ); + if (isProd) parts.push('upgrade-insecure-requests'); + return parts.join('; '); +} + +const cspHeader = { + key: + process.env.CSP_ENFORCE === 'true' + ? 'Content-Security-Policy' + : 'Content-Security-Policy-Report-Only', + value: contentSecurityPolicy(), +}; + const securityHeaders = [ { key: 'X-DNS-Prefetch-Control', @@ -27,6 +83,7 @@ const securityHeaders = [ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }, + cspHeader, ]; const config: NextConfig = {