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
35 changes: 11 additions & 24 deletions apps/landing-page/__tests__/config/csp-headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
import { describe, it, expect } from 'vitest';
import nextConfig from '@/next.config';

describe('CSP Headers', () => {
it('should include Vercel Analytics domains in CSP', async () => {
describe('CSP Headers (next.config.ts)', () => {
it('should NOT include static CSP header (handled by middleware)', async () => {
const headers = await nextConfig.headers!();
const globalHeaders = headers[0].headers;
const cspHeader = globalHeaders.find(h => h.key === 'Content-Security-Policy');

expect(cspHeader).toBeDefined();
const cspValue = cspHeader!.value;

// script-src must allow Vercel Analytics script loading
expect(cspValue).toContain('va.vercel-scripts.com');
// connect-src must allow analytics data reporting
expect(cspValue).toContain('vitals.vercel-insights.com');
});

it('should have restrictive defaults', async () => {
const headers = await nextConfig.headers!();
const cspHeader = headers[0].headers.find(h => h.key === 'Content-Security-Policy');
const cspValue = cspHeader!.value;

expect(cspValue).toContain("default-src 'self'");
expect(cspValue).toContain("frame-ancestors 'none'");
expect(cspValue).toContain("base-uri 'self'");
expect(cspValue).toContain("form-action 'self'");
expect(cspHeader).toBeUndefined();
});

it('should allow unsafe-inline for script-src to support Next.js PPR', async () => {
it('should still include other security headers', async () => {
const headers = await nextConfig.headers!();
const cspHeader = headers[0].headers.find(h => h.key === 'Content-Security-Policy');
const cspValue = cspHeader!.value;
const globalHeaders = headers[0].headers;
const headerKeys = globalHeaders.map(h => h.key);

expect(cspValue).toContain("'unsafe-inline'");
expect(headerKeys).toContain('X-Frame-Options');
expect(headerKeys).toContain('X-Content-Type-Options');
expect(headerKeys).toContain('Referrer-Policy');
expect(headerKeys).toContain('Permissions-Policy');
expect(headerKeys).toContain('Strict-Transport-Security');
});
});
54 changes: 54 additions & 0 deletions apps/landing-page/__tests__/config/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { buildCspHeader } from '@/config/csp';

describe('buildCspHeader', () => {
const TEST_NONCE = 'test-nonce-abc123';

it('should include nonce in script-src for production', () => {
const csp = buildCspHeader(TEST_NONCE, 'production');
const scriptSrc = csp.split('; ').find(d => d.startsWith('script-src'));

expect(scriptSrc).toContain(`'nonce-${TEST_NONCE}'`);
expect(scriptSrc).not.toContain("'unsafe-inline'");
});

it('should include Vercel Analytics domain in script-src for production', () => {
const csp = buildCspHeader(TEST_NONCE, 'production');

expect(csp).toContain('https://va.vercel-scripts.com');
});

it('should include unsafe-eval in script-src for development', () => {
const csp = buildCspHeader(TEST_NONCE, 'development');

expect(csp).toContain("'unsafe-eval'");
});

it('should include nonce in script-src for development too', () => {
const csp = buildCspHeader(TEST_NONCE, 'development');

expect(csp).toContain(`'nonce-${TEST_NONCE}'`);
});

it('should have restrictive defaults', () => {
const csp = buildCspHeader(TEST_NONCE, 'production');

expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("base-uri 'self'");
expect(csp).toContain("form-action 'self'");
});

it('should allow analytics connect-src', () => {
const csp = buildCspHeader(TEST_NONCE, 'production');

expect(csp).toContain('https://va.vercel-scripts.com');
expect(csp).toContain('https://vitals.vercel-insights.com');
});

it('should allow unsafe-inline in style-src', () => {
const csp = buildCspHeader(TEST_NONCE, 'production');

expect(csp).toContain("style-src 'self' 'unsafe-inline'");
});
});
77 changes: 77 additions & 0 deletions apps/landing-page/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';

// Mock next-intl middleware
vi.mock('next-intl/middleware', () => ({
default: vi.fn(() => {
return (request: NextRequest) => {
return NextResponse.next({
request: { headers: new Headers(request.headers) },
});
};
}),
}));

vi.mock('./i18n/routing', () => ({
routing: { locales: ['en', 'ko'], defaultLocale: 'en' },
}));

describe('middleware', () => {
beforeEach(() => {
vi.resetModules();
});

const createRequest = (path = '/en') => new NextRequest(new URL(path, 'http://localhost:3000'));

it('should set x-nonce request header', async () => {
const { default: middleware } = await import('@/middleware');
const request = createRequest();
const response = await middleware(request);

const nonce = response.headers.get('x-nonce');
expect(nonce).toBeTruthy();
expect(typeof nonce).toBe('string');
expect(nonce!.length).toBeGreaterThan(0);
});

it('should set Content-Security-Policy response header with nonce', async () => {
const { default: middleware } = await import('@/middleware');
const request = createRequest();
const response = await middleware(request);

const csp = response.headers.get('Content-Security-Policy');
const nonce = response.headers.get('x-nonce');

expect(csp).toBeTruthy();
expect(csp).toContain(`'nonce-${nonce}'`);
});

it('should not include unsafe-inline in script-src of CSP', async () => {
const { default: middleware } = await import('@/middleware');
const request = createRequest();
const response = await middleware(request);

const csp = response.headers.get('Content-Security-Policy');
const scriptSrc = csp!.split('; ').find(d => d.startsWith('script-src'));

expect(scriptSrc).not.toContain("'unsafe-inline'");
});

it('should generate unique nonce per request', async () => {
const { default: middleware } = await import('@/middleware');

const response1 = await middleware(createRequest());
const response2 = await middleware(createRequest());

const nonce1 = response1.headers.get('x-nonce');
const nonce2 = response2.headers.get('x-nonce');

expect(nonce1).not.toBe(nonce2);
});

it('should export config with matcher', async () => {
const { config } = await import('@/middleware');
expect(config).toBeDefined();
expect(config.matcher).toBeDefined();
});
});
21 changes: 21 additions & 0 deletions apps/landing-page/config/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
type Environment = 'development' | 'production' | 'test';

export const buildCspHeader = (nonce: string, env: Environment = 'production'): string => {
const isDev = env === 'development';

const scriptSrc = isDev
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' 'unsafe-inline'`
: `script-src 'self' 'nonce-${nonce}' https://va.vercel-scripts.com`;

return [
"default-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: https: blob:",
"connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
};
25 changes: 23 additions & 2 deletions apps/landing-page/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import createMiddleware from 'next-intl/middleware';
import { type NextRequest, NextResponse } from 'next/server';
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { buildCspHeader } from './config/csp';

export default createMiddleware(routing);
const intlMiddleware = createIntlMiddleware(routing);

const middleware = (request: NextRequest): NextResponse => {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const env = process.env.NODE_ENV ?? 'production';
const cspHeader = buildCspHeader(nonce, env as 'development' | 'production');

// Set nonce on request headers for downstream consumption
request.headers.set('x-nonce', nonce);

const response = intlMiddleware(request) as NextResponse;

// Set CSP and nonce on response headers
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('x-nonce', nonce);

return response;
};

export default middleware;

export const config = {
matcher: ['/((?!api|_next|_vercel|monitoring|.*\\..*).*)'],
Expand Down
21 changes: 2 additions & 19 deletions apps/landing-page/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,8 @@ const nextConfig: NextConfig = {
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
// Content Security Policy
// 'unsafe-inline' is required for Next.js PPR hydration/streaming inline scripts.
// Acceptable for this landing page (no user auth, no sensitive data).
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
process.env.NODE_ENV === 'development'
? "script-src 'self' 'unsafe-eval' 'unsafe-inline'"
: "script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: https: blob:",
"connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
// Content Security Policy is set dynamically by middleware.ts
// with per-request nonce for script-src (replaces unsafe-inline).
],
},
];
Expand Down
Loading