Skip to content

Commit a86355e

Browse files
authored
Merge pull request #610 from odarome132/feat/security/add-nextjs-security-headers
feat(web): add security headers to corporate platform Next.js config
2 parents 3b7c6fe + db94df9 commit a86355e

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Security Headers — corporate-platform-web
2+
3+
This document is the single source of truth for the browser-side security
4+
headers served by the corporate platform web app. It exists so future changes
5+
to the hosting layer (`vercel.json` or similar) do not silently conflict with
6+
what `next.config.ts` emits.
7+
8+
## Where the headers are defined
9+
10+
All security headers are defined in [`next.config.ts`](./next.config.ts) inside
11+
the `headers()` function (`buildSecurityHeaders()`), applied to **every route**
12+
via:
13+
14+
```ts
15+
{ source: '/(.*)', headers: buildSecurityHeaders() }
16+
```
17+
18+
The existing per-asset `Cache-Control` rules are untouched and coexist with the
19+
security rule (no header key overlaps).
20+
21+
## Header set
22+
23+
| Header | Value | Applies in |
24+
| --- | --- | --- |
25+
| `Content-Security-Policy` | see [CSP section](#content-security-policy) | dev + prod |
26+
| `X-Frame-Options` | `DENY` | dev + prod |
27+
| `X-Content-Type-Options` | `nosniff` | dev + prod |
28+
| `Referrer-Policy` | `strict-origin-when-cross-origin` | dev + prod |
29+
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | dev + prod |
30+
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | **production only** |
31+
32+
### Environment differences
33+
34+
- **Strict-Transport-Security** is gated behind
35+
`process.env.NODE_ENV === 'production'`. Sending a long `max-age` in dev
36+
would make browsers refuse plain `http://localhost`.
37+
- The **dev CSP** additionally allows `'unsafe-eval'` (needed by some dev tooling)
38+
and `ws:` / `wss:` (Next.js hot reload). Production drops both.
39+
40+
## Content-Security-Policy
41+
42+
Production policy:
43+
44+
```text
45+
default-src 'self';
46+
script-src 'self' 'unsafe-inline';
47+
style-src 'self' 'unsafe-inline';
48+
img-src 'self' data: blob: https://images.unsplash.com https://*.pinata.cloud https://cdn.jsdelivr.net https://*.stellar.org;
49+
font-src 'self' data: https://cdn.jsdelivr.net;
50+
connect-src 'self' <NEXT_PUBLIC_API_BASE_URL>;
51+
object-src 'none';
52+
base-uri 'self';
53+
form-action 'self';
54+
frame-ancestors 'none'
55+
```
56+
57+
Notes:
58+
59+
- **`img-src` mirrors `images.remotePatterns`.** If you add a host to
60+
`remotePatterns`, add it to `img-src` too (there is a test asserting they are
61+
in sync).
62+
- **`connect-src` is built from `NEXT_PUBLIC_API_BASE_URL`** (default
63+
`http://localhost:4000`). API calls are therefore never blocked once the
64+
policy is enforced.
65+
- **`frame-ancestors 'none'`** (plus `X-Frame-Options: DENY`) blocks
66+
clickjacking: the app refuses to render inside any `<iframe>`.
67+
- `script-src` keeps `'unsafe-inline'` because Next.js injects inline bootstrap
68+
scripts. This is the documented minimum until a nonce strategy lands (below).
69+
70+
## Coordination with the deployment layer (`vercel.json`)
71+
72+
There is currently **no `vercel.json`** for this app — `next.config.ts` is the
73+
only source of headers. If platform-level headers are ever added at the hosting
74+
layer:
75+
76+
1. **Do not duplicate** security headers already emitted here; Vercel
77+
platform-level headers take precedence over Next.js config for the same key,
78+
so a stale copy in `vercel.json` would silently override (or drift from)
79+
this file.
80+
2. If you must set a header at the platform layer, mirror the **exact** value
81+
from the table above and add a comment in both places pointing at this
82+
document.
83+
3. Keep the `HSTS` production-only rule intact — do not promote it to
84+
`vercel.json` for all environments, or local preview deployments over plain
85+
HTTP will be affected.
86+
87+
## Future work
88+
89+
- **Nonce/hash strategy for `script-src`:** replace `'unsafe-inline'` with a
90+
per-request nonce (e.g. via a Next.js middleware or a custom server) so inline
91+
bootstrap scripts are the only inline scripts allowed. Until then, `'unsafe-inline'`
92+
is required and any client-side inline `<script>` will execute.
93+
- **Report-Only rollout:** consider shipping a `Content-Security-Policy-Report-Only`
94+
header with a reporting endpoint (`NEXT_PUBLIC_ERROR_REPORTING_ENDPOINT` is a
95+
candidate sink) before tightening further.

corporate-platform/corporate-platform-web/next.config.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,75 @@
11
import type { NextConfig } from "next";
22
import './src/env';
33

4+
/**
5+
* Security Headers
6+
*
7+
* Baseline browser-side hardening applied to every route via the `/(.*)`
8+
* rule in `headers()`. See SECURITY_HEADERS.md for the full design and for
9+
* how these coordinate with any future platform-level (vercel.json) headers.
10+
*
11+
* Environment differences:
12+
* - Strict-Transport-Security is production-only: a dev-mode max-age could
13+
* lock browsers out of plain http://localhost.
14+
* - The dev CSP allows 'unsafe-eval' and ws:/wss: so Next.js hot reload works;
15+
* production drops both.
16+
* - script-src keeps 'unsafe-inline' because Next.js injects inline bootstrap
17+
* scripts and no nonce/hash strategy exists yet. Tightening this requires a
18+
* nonce-based script-src (see SECURITY_HEADERS.md → Future work).
19+
*/
20+
function buildSecurityHeaders(): { key: string; value: string }[] {
21+
const isProduction = process.env.NODE_ENV === 'production';
22+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4000';
23+
24+
// Must stay in sync with images.remotePatterns so third-party images are
25+
// never blocked by the CSP once it is enforced.
26+
const imageSources = [
27+
'https://images.unsplash.com',
28+
'https://*.pinata.cloud',
29+
'https://cdn.jsdelivr.net',
30+
'https://*.stellar.org',
31+
].join(' ');
32+
33+
const scriptSrc = isProduction
34+
? "'self' 'unsafe-inline'"
35+
: "'self' 'unsafe-inline' 'unsafe-eval'";
36+
37+
// API calls (direct calls to the configured API base URL must not be
38+
// blocked), plus WebSocket for HMR in dev.
39+
const connectSrc = isProduction
40+
? `'self' ${apiBaseUrl}`
41+
: `'self' ${apiBaseUrl} ws: wss:`;
42+
43+
const contentSecurityPolicy = [
44+
"default-src 'self'",
45+
`script-src ${scriptSrc}`,
46+
"style-src 'self' 'unsafe-inline'",
47+
`img-src 'self' data: blob: ${imageSources}`,
48+
"font-src 'self' data: https://cdn.jsdelivr.net",
49+
`connect-src ${connectSrc}`,
50+
"object-src 'none'",
51+
"base-uri 'self'",
52+
"form-action 'self'",
53+
"frame-ancestors 'none'",
54+
].join('; ');
55+
56+
return [
57+
{ key: 'X-Frame-Options', value: 'DENY' },
58+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
59+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
60+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
61+
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
62+
...(isProduction
63+
? [
64+
{
65+
key: 'Strict-Transport-Security',
66+
value: 'max-age=63072000; includeSubDomains; preload',
67+
},
68+
]
69+
: []),
70+
];
71+
}
72+
473
const nextConfig: NextConfig = {
574
images: {
675
remotePatterns: [
@@ -59,6 +128,11 @@ const nextConfig: NextConfig = {
59128
*/
60129
async headers() {
61130
return [
131+
// Baseline security headers for every route (see buildSecurityHeaders above)
132+
{
133+
source: '/(.*)',
134+
headers: buildSecurityHeaders(),
135+
},
62136
// Static assets (JS, CSS, chunks) - 1 year immutable
63137
{
64138
source: '/_next/static/:path*',
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import nextConfig from '../next.config';
3+
4+
type Header = { key: string; value: string };
5+
type HeaderRule = { source: string; headers: Header[] };
6+
7+
const ALL_ROUTES_SOURCE = '/(.*)';
8+
9+
async function getRule(source: string): Promise<HeaderRule> {
10+
if (typeof nextConfig.headers !== 'function') {
11+
throw new Error('next.config does not export a headers() function');
12+
}
13+
const rules = (await nextConfig.headers()) as HeaderRule[];
14+
const rule = rules.find((r) => r.source === source);
15+
if (!rule) throw new Error(`Expected a header rule for source "${source}"`);
16+
return rule;
17+
}
18+
19+
async function getSecurityHeaders(): Promise<Header[]> {
20+
return (await getRule(ALL_ROUTES_SOURCE)).headers;
21+
}
22+
23+
function headerValue(headers: Header[], key: string): string | undefined {
24+
return headers.find((h) => h.key === key)?.value;
25+
}
26+
27+
function cspDirective(csp: string, name: string): string {
28+
const match = csp.match(new RegExp(`${name}\\s+([^;]+)`));
29+
if (!match) throw new Error(`Missing CSP directive: ${name}`);
30+
return match[1].trim();
31+
}
32+
33+
describe('next.config security headers', () => {
34+
afterEach(() => {
35+
vi.unstubAllEnvs();
36+
delete process.env.NEXT_PUBLIC_API_BASE_URL;
37+
});
38+
39+
it('applies a baseline security header set to all routes', async () => {
40+
const headers = await getSecurityHeaders();
41+
42+
expect(headerValue(headers, 'X-Frame-Options')).toBe('DENY');
43+
expect(headerValue(headers, 'X-Content-Type-Options')).toBe('nosniff');
44+
expect(headerValue(headers, 'Referrer-Policy')).toBe(
45+
'strict-origin-when-cross-origin',
46+
);
47+
expect(headerValue(headers, 'Permissions-Policy')).toBe(
48+
'camera=(), microphone=(), geolocation=()',
49+
);
50+
expect(headerValue(headers, 'Content-Security-Policy')).toBeDefined();
51+
});
52+
53+
it('only emits Strict-Transport-Security in production', async () => {
54+
vi.stubEnv('NODE_ENV', 'development');
55+
expect(
56+
headerValue(await getSecurityHeaders(), 'Strict-Transport-Security'),
57+
).toBeUndefined();
58+
59+
vi.stubEnv('NODE_ENV', 'production');
60+
expect(
61+
headerValue(await getSecurityHeaders(), 'Strict-Transport-Security'),
62+
).toBe('max-age=63072000; includeSubDomains; preload');
63+
});
64+
65+
it('CSP blocks framing via frame-ancestors and allows required script sources', async () => {
66+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
67+
expect(csp).toBeDefined();
68+
if (!csp) return;
69+
70+
expect(cspDirective(csp, 'frame-ancestors')).toBe("'none'");
71+
const scriptSrc = cspDirective(csp, 'script-src');
72+
expect(scriptSrc).toContain("'self'");
73+
expect(scriptSrc).toContain("'unsafe-inline'");
74+
});
75+
76+
it('CSP connect-src allows the default API base URL', async () => {
77+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
78+
expect(csp).toBeDefined();
79+
if (!csp) return;
80+
81+
const connectSrc = cspDirective(csp, 'connect-src');
82+
expect(connectSrc).toContain("'self'");
83+
expect(connectSrc).toContain('http://localhost:4000');
84+
});
85+
86+
it('CSP connect-src allows a custom NEXT_PUBLIC_API_BASE_URL', async () => {
87+
vi.stubEnv('NEXT_PUBLIC_API_BASE_URL', 'https://api.example.com');
88+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
89+
expect(csp).toBeDefined();
90+
if (!csp) return;
91+
92+
expect(cspDirective(csp, 'connect-src')).toContain('https://api.example.com');
93+
});
94+
95+
it('CSP img-src covers every images.remotePatterns host', async () => {
96+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
97+
expect(csp).toBeDefined();
98+
if (!csp) return;
99+
100+
const imgSrc = cspDirective(csp, 'img-src');
101+
for (const host of [
102+
'https://images.unsplash.com',
103+
'https://*.pinata.cloud',
104+
'https://cdn.jsdelivr.net',
105+
'https://*.stellar.org',
106+
]) {
107+
expect(imgSrc).toContain(host);
108+
}
109+
});
110+
111+
it('dev CSP keeps hot reload working (unsafe-eval + WebSocket)', async () => {
112+
vi.stubEnv('NODE_ENV', 'development');
113+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
114+
expect(csp).toBeDefined();
115+
if (!csp) return;
116+
117+
expect(cspDirective(csp, 'script-src')).toContain("'unsafe-eval'");
118+
const connectSrc = cspDirective(csp, 'connect-src');
119+
expect(connectSrc).toContain('ws:');
120+
expect(connectSrc).toContain('wss:');
121+
});
122+
123+
it('production CSP is stricter (no unsafe-eval, no WebSocket)', async () => {
124+
vi.stubEnv('NODE_ENV', 'production');
125+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
126+
expect(csp).toBeDefined();
127+
if (!csp) return;
128+
129+
expect(cspDirective(csp, 'script-src')).not.toContain("'unsafe-eval'");
130+
const connectSrc = cspDirective(csp, 'connect-src');
131+
expect(connectSrc).not.toContain('ws:');
132+
expect(connectSrc).not.toContain('wss:');
133+
});
134+
135+
it('blocks plugins and confines the document base', async () => {
136+
const csp = headerValue(await getSecurityHeaders(), 'Content-Security-Policy');
137+
expect(csp).toBeDefined();
138+
if (!csp) return;
139+
140+
expect(cspDirective(csp, 'object-src')).toBe("'none'");
141+
expect(cspDirective(csp, 'base-uri')).toBe("'self'");
142+
expect(cspDirective(csp, 'form-action')).toBe("'self'");
143+
});
144+
145+
it('preserves the existing caching header rules', async () => {
146+
const staticRule = await getRule('/_next/static/:path*');
147+
expect(staticRule.headers).toEqual(
148+
expect.arrayContaining([
149+
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
150+
{ key: 'Vary', value: 'Accept-Encoding' },
151+
]),
152+
);
153+
154+
const imageRule = await getRule('/_next/image/:path*');
155+
expect(headerValue(imageRule.headers, 'Cache-Control')).toBe(
156+
'public, max-age=31536000, immutable',
157+
);
158+
});
159+
160+
it('does not add security headers to the caching rules (no accidental overrides)', async () => {
161+
const staticRule = await getRule('/_next/static/:path*');
162+
expect(headerValue(staticRule.headers, 'X-Frame-Options')).toBeUndefined();
163+
expect(headerValue(staticRule.headers, 'Content-Security-Policy')).toBeUndefined();
164+
});
165+
});

0 commit comments

Comments
 (0)