Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion SESSION-HANDOVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/security/code-audit-2026-07-15.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
57 changes: 57 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +10 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the em-dash.

Line 12 uses an em-dash ("header — no code change required"). As per coding guidelines, **/*.{ts,tsx,js,jsx}: "do not use em-dashes. Use periods, commas, or parentheses instead."

Proposed fix
-// `Content-Security-Policy` header — no code change required.
+// `Content-Security-Policy` header (no code change required).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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.
// 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).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@next.config.ts` around lines 10 - 12, Update the comment in the CSP
configuration guidance to replace the em-dash between “header” and “no code
change required” with punctuation permitted by the coding guidelines, preserving
the original meaning.

Source: Coding guidelines

//
// 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<string, string[]> = {
'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('; ');
}
Comment on lines +23 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

No tests added for the new contentSecurityPolicy() logic.

This is a new feature (env-sensitive directive builder with prod/dev and CSP_ENFORCE branching). As per coding guidelines, **/*.{ts,tsx,md,mdx}: "New features must include tests." A small unit test asserting 'unsafe-eval'/upgrade-insecure-requests toggle correctly with NODE_ENV, and that the header key switches on CSP_ENFORCE, would catch regressions cheaply given this is a security-relevant config.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@next.config.ts` around lines 23 - 51, Add unit tests for
contentSecurityPolicy() covering production versus non-production behavior for
'unsafe-eval' and upgrade-insecure-requests, plus CSP_ENFORCE-driven selection
of the response header key. Ensure tests isolate and restore NODE_ENV and
CSP_ENFORCE so they do not affect other configuration tests.

Source: Coding guidelines


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',
Expand All @@ -27,6 +83,7 @@ const securityHeaders = [
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
cspHeader,
];

const config: NextConfig = {
Expand Down