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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> (see docs/csp.md)
CSP_ENFORCE=

# -----------------------------------------------------------------------------
# Security / CSRF Protection
Expand Down
38 changes: 31 additions & 7 deletions docs/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,24 @@ 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

CSP violations are reported to `POST /api/csp-report`. In development mode, reports are logged to the console.

## 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.

Expand All @@ -43,18 +46,39 @@ 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:

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
Expand Down
5 changes: 5 additions & 0 deletions scripts/validate-env.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
32 changes: 32 additions & 0 deletions src/config/env/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
20 changes: 17 additions & 3 deletions src/config/env/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -249,7 +262,8 @@ export const envVariableDescriptions: Record<keyof EnvConfig, string> = {
"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",
Expand Down
23 changes: 23 additions & 0 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 25 additions & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading