From 8ab41af1b8562590760c750a322bbba2d4d7c6c7 Mon Sep 17 00:00:00 2001 From: Peniel Samuel Date: Tue, 29 Sep 2026 22:12:44 +0000 Subject: [PATCH] fix(security): default CSP_ENFORCE to enforced in production/staging (#1107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSP_ENFORCE previously defaulted to false, so production served no nonce-based CSP unless operators explicitly opted in — an insecure default that made the production posture ambiguous. Secure, environment-aware default: - unset/empty: enforced in production/staging, disabled in development - "true"/"false": explicit override (false warns at boot outside dev) Also: - Schema rejects values other than "true"/"false"/"" (#1107) - .env.example documents the default and how to verify - docs/csp.md: decision table, curl -I verification steps, exclusions check - validate-env.cjs warns when CSP is explicitly disabled in production - Unit tests pin the default behavior in middleware and schema Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- .env.example | 8 ++++-- docs/csp.md | 38 ++++++++++++++++++++----- scripts/validate-env.cjs | 5 ++++ src/config/env/__tests__/schema.test.ts | 32 +++++++++++++++++++++ src/config/env/schema.ts | 20 +++++++++++-- src/middleware.test.ts | 23 +++++++++++++++ src/middleware.ts | 26 ++++++++++++++++- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 494ab6a4..8cf1c2bb 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,12 @@ NODE_ENV=development # Enable build analysis (set to "true" for bundle analysis) ANALYZE=false -# Enable CSP enforcement (set to "true" after report-only rollout) -CSP_ENFORCE=false +# Enable CSP enforcement for the nonce-based CSP built in src/middleware.ts. +# Secure default (#1107): when unset/empty, CSP is ENFORCED in production and +# staging and disabled in development. Set "true" to enforce everywhere (e.g. +# to test CSP locally); set "false" only to debug CSP issues in production. +# Verify enforcement with: curl -I (see docs/csp.md) +CSP_ENFORCE= # ----------------------------------------------------------------------------- # Security / CSRF Protection diff --git a/docs/csp.md b/docs/csp.md index d7cd89a2..eed505e7 100644 --- a/docs/csp.md +++ b/docs/csp.md @@ -19,8 +19,7 @@ PropChain enforces a strict Content Security Policy to prevent XSS attacks. The ## Environment Behavior -- **Production**: `Content-Security-Policy` header (enforced) -- **Non-production**: `Content-Security-Policy-Report-Only` header (reported only) +Whether the enforcing header is sent is controlled by `CSP_ENFORCE` — see the table below. Since issue #1107, the secure default enforces the CSP in production and staging without any configuration. ## CSP Reports @@ -28,12 +27,16 @@ CSP violations are reported to `POST /api/csp-report`. In development mode, repo ## Environment Control: `CSP_ENFORCE` -The middleware uses the environment variable `CSP_ENFORCE` to toggle between **enforcement** and **report-only** modes: +The middleware uses the environment variable `CSP_ENFORCE` to toggle between **enforcement** and **report-only** modes. As of issue #1107 it has a **secure, environment-aware default**: | `CSP_ENFORCE` | Environment | Header Sent | Behaviour | |---|---|---|---| | `"true"` | Any | `Content-Security-Policy` | Violations are **blocked** by the browser | -| anything else (or unset) | Any | *No CSP header* | CSP is disabled entirely | +| `"false"` | Any | *No CSP header* | CSP is disabled entirely (warns at boot outside development) | +| unset / empty | production, staging | `Content-Security-Policy` | **Secure default: enforced** | +| unset / empty | development | *No CSP header* | Dev convenience: no nonce bookkeeping while coding | + +> **Default decision (#1107):** CSP is **enforced in production and staging by default** — no configuration required. Developers only need to set `CSP_ENFORCE=true` explicitly when they want to test the policy locally. An explicit `"false"` in production logs a warning at boot and at `npm run validate:env`. > **Note**: In development (`NODE_ENV=development`), the `script-src` directive includes `'unsafe-eval'` to support hot reload. This is **never** included in production builds. @@ -43,10 +46,31 @@ The middleware uses the environment variable `CSP_ENFORCE` to toggle between **e # .env.local (development — CSP disabled by default for easier debugging) # CSP_ENFORCE=true # uncomment to test CSP enforcement locally -# .env.production (production — CSP should be enforced) -CSP_ENFORCE=true +# .env.production (optional — already enforced by the secure default) +# CSP_ENFORCE=true +``` + +### Verifying enforcement (`curl -I`) + +After deploying, confirm the policy is live: + +```bash +# 1. Enforcing header present (secure default in production/staging): +curl -sI https://your-domain.example.com/ | grep -i content-security-policy +# → content-security-policy: default-src 'self'; script-src 'self' 'nonce-...'; ... + +# 2. The nonce is unique per request: +curl -sI https://your-domain.example.com/ | grep -oiP "nonce-\K[A-Za-z0-9+/=]+" +curl -sI https://your-domain.example.com/ | grep -oiP "nonce-\K[A-Za-z0-9+/=]+" +# → two different values = per-request nonce working + +# 3. Exclusions (no CSP header expected): +curl -sI https://your-domain.example.com/api/health | grep -i content-security-policy +# → (no output) ``` +If you get **no** CSP header in production, check that the middleware matcher isn't excluding the route and that `CSP_ENFORCE` isn't explicitly set to `"false"` — the boot log and `npm run validate:env` both warn about it. + ### How to extend the CSP To add new directives or allow additional origins: @@ -54,7 +78,7 @@ To add new directives or allow additional origins: 1. Edit `src/middleware.ts` → `buildCspHeader()`. 2. Add the new directive to the `directives` array. 3. Ensure nonce-based scripts are properly handled (the `x-nonce` request header is forwarded). -4. Test in report-only mode first by setting `CSP_ENFORCE=false` and checking the browser console for violation reports. +4. Test in development first by setting `CSP_ENFORCE=true` locally and checking the browser console for violation reports. 5. Violations are automatically posted to `POST /api/csp-report` for monitoring. ## Exclusions diff --git a/scripts/validate-env.cjs b/scripts/validate-env.cjs index c57d0227..ddb7119d 100644 --- a/scripts/validate-env.cjs +++ b/scripts/validate-env.cjs @@ -181,6 +181,11 @@ function main() { if (config.NODE_ENV === "production" && config.NEXT_PUBLIC_ENABLE_DEMOS) { warnings.push("Demo routes are enabled in production - they should 404 there"); } + // Issue #1107 - the secure default enforces CSP in production; an explicit + // opt-out is worth surfacing to whoever runs this check. + if (config.NODE_ENV === "production" && config.CSP_ENFORCE === false) { + warnings.push("CSP_ENFORCE is disabled - the nonce-based CSP will NOT be enforced (see docs/csp.md)"); + } if (config.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID === "your-walletconnect-project-id") { warnings.push("WalletConnect Project ID is still set to the example value"); } diff --git a/src/config/env/__tests__/schema.test.ts b/src/config/env/__tests__/schema.test.ts index a00701f4..fb0313a2 100644 --- a/src/config/env/__tests__/schema.test.ts +++ b/src/config/env/__tests__/schema.test.ts @@ -23,6 +23,27 @@ describe('envSchema', () => { expect(result.data.NEXT_PUBLIC_SUPPORTED_LOCALES).toBe('en,es,fr,de,zh,ar,he'); }); + it('defaults CSP_ENFORCE to true in production and staging, false in development (#1107)', () => { + const originalNodeEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + expect(envSchema.safeParse({}).data?.CSP_ENFORCE).toBe(true); + + process.env.NODE_ENV = 'staging'; + expect(envSchema.safeParse({}).data?.CSP_ENFORCE).toBe(true); + + process.env.NODE_ENV = 'development'; + expect(envSchema.safeParse({}).data?.CSP_ENFORCE).toBe(false); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('rejects invalid CSP_ENFORCE values (#1107)', () => { + expect(envSchema.safeParse({ CSP_ENFORCE: 'yes' }).success).toBe(false); + expect(envSchema.safeParse({ CSP_ENFORCE: '1' }).success).toBe(false); + }); + it('transforms string booleans correctly', () => { const result = envSchema.safeParse({ CSP_ENFORCE: 'true', @@ -50,6 +71,17 @@ describe('envSchema', () => { expect(result.data.NEXT_PUBLIC_SKIP_AUTH).toBe(false); }); + it('lets an explicit CSP_ENFORCE=false override the secure default (#1107)', () => { + const originalNodeEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + expect(envSchema.safeParse({ CSP_ENFORCE: 'false' }).data?.CSP_ENFORCE).toBe(false); + expect(envSchema.safeParse({ CSP_ENFORCE: '' }).data?.CSP_ENFORCE).toBe(true); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + it('parses rate limit numeric strings', () => { const result = envSchema.safeParse({ RATE_LIMIT_WINDOW_MS: '60000', diff --git a/src/config/env/schema.ts b/src/config/env/schema.ts index 6b35a1af..1f2e435d 100644 --- a/src/config/env/schema.ts +++ b/src/config/env/schema.ts @@ -24,10 +24,23 @@ const envSchema = z.object({ .enum(["development", "staging", "production"]) .default("development"), ANALYZE: z.string().optional().default("false"), + // Issue #1107 — secure default. Only "true"/"false" are accepted (empty = + // unset). When unset, CSP is enforced in production/staging and disabled in + // development, mirroring resolveCspEnforcement() in src/middleware.ts. CSP_ENFORCE: z .string() - .transform((val) => val === "true") - .default(false), + .refine((val) => val === "" || val === "true" || val === "false", { + message: 'CSP_ENFORCE must be "true" or "false"', + }) + .optional() + .transform((val) => { + if (val === "true") return true; + if (val === "false") return false; + // String() first: Next.js types NODE_ENV as a literal union without + // "staging", which would make the comparison a TS2367 error. + const nodeEnv = String(process.env.NODE_ENV); + return nodeEnv === "production" || nodeEnv === "staging"; + }), // API Configurations NEXT_PUBLIC_PROPERTY_API_URL: z.string().url().optional(), @@ -249,7 +262,8 @@ export const envVariableDescriptions: Record = { "Base URL for the application (include protocol and trailing slash)", NODE_ENV: "Current deployment environment (development, staging, production)", ANALYZE: 'Enable build analysis (set to "true" for bundle analysis)', - CSP_ENFORCE: "Enable CSP enforcement (report-only when false)", + CSP_ENFORCE: + 'Enable CSP enforcement. Secure default (#1107): enforced in production/staging when unset; set "false" to opt out', NEXT_PUBLIC_PROPERTY_API_URL: "Property API endpoint for fetching property data", NEXT_PUBLIC_ANALYTICS_API_URL: "Analytics API endpoint", diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 4de87125..c6264f5a 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -115,6 +115,29 @@ describe('middleware CSP enforcement, Redis init', () => { process.env = originalEnv; }); + it('enforces CSP by default in production when CSP_ENFORCE is unset (#1107)', async () => { + delete process.env.CSP_ENFORCE; + jest.resetModules(); + resetState(); + + const { middleware } = await import('./middleware'); + await middleware(createMockRequest('/')); + + expect(capturedHeaders.has('Content-Security-Policy')).toBe(true); + }); + + it('does not enforce CSP by default in development when unset (#1107)', async () => { + delete process.env.CSP_ENFORCE; + process.env.NODE_ENV = 'development'; + jest.resetModules(); + resetState(); + + const { middleware } = await import('./middleware'); + await middleware(createMockRequest('/')); + + expect(capturedHeaders.has('Content-Security-Policy')).toBe(false); + }); + it('returns NextResponse.next() when CSP_ENFORCE is not true', async () => { process.env.CSP_ENFORCE = 'false'; jest.resetModules(); diff --git a/src/middleware.ts b/src/middleware.ts index 2e844471..be171879 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,7 +10,31 @@ import { initRedisCacheSystem } from '@/lib/initRedisCache'; import { logger } from '@/utils/logger'; const isDev = process.env.NODE_ENV === 'development'; -const isCspEnforced = process.env.CSP_ENFORCE === 'true'; + +/** + * Issue #1107 — CSP enforcement has a secure default: + * - `CSP_ENFORCE="true"` → always enforce the nonce-based CSP + * - `CSP_ENFORCE="false"` → opt out entirely (warns outside development) + * - unset/empty/other → enforce outside development; development stays + * off so hot reload and debugging remain easy + * This mirrors the environment-aware default applied in + * src/config/env/schema.ts. + */ +const resolveCspEnforcement = (): boolean => { + const raw = process.env.CSP_ENFORCE?.trim().toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + return !isDev; +}; + +const isCspEnforced = resolveCspEnforcement(); + +if (process.env.CSP_ENFORCE?.trim().toLowerCase() === 'false' && !isDev) { + logger.warn( + '[CSP] CSP_ENFORCE=false — the nonce-based Content-Security-Policy is NOT enforced. ' + + 'Remove CSP_ENFORCE (secure default) or set it to "true" in production. See docs/csp.md.', + ); +} // Admin routes require an authenticated session. This project has no // roles/permissions system yet, so this closes the "fully public admin