diff --git a/apps/landing-page/__tests__/config/csp-headers.test.ts b/apps/landing-page/__tests__/config/csp-headers.test.ts index c83fddb4..35cb93ec 100644 --- a/apps/landing-page/__tests__/config/csp-headers.test.ts +++ b/apps/landing-page/__tests__/config/csp-headers.test.ts @@ -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'); }); }); diff --git a/apps/landing-page/__tests__/config/csp.test.ts b/apps/landing-page/__tests__/config/csp.test.ts new file mode 100644 index 00000000..51e73e0a --- /dev/null +++ b/apps/landing-page/__tests__/config/csp.test.ts @@ -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'"); + }); +}); diff --git a/apps/landing-page/__tests__/middleware.test.ts b/apps/landing-page/__tests__/middleware.test.ts new file mode 100644 index 00000000..3c53c4d9 --- /dev/null +++ b/apps/landing-page/__tests__/middleware.test.ts @@ -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(); + }); +}); diff --git a/apps/landing-page/config/csp.ts b/apps/landing-page/config/csp.ts new file mode 100644 index 00000000..04683a81 --- /dev/null +++ b/apps/landing-page/config/csp.ts @@ -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('; '); +}; diff --git a/apps/landing-page/middleware.ts b/apps/landing-page/middleware.ts index f25914c2..80836016 100644 --- a/apps/landing-page/middleware.ts +++ b/apps/landing-page/middleware.ts @@ -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|.*\\..*).*)'], diff --git a/apps/landing-page/next.config.ts b/apps/landing-page/next.config.ts index d3b83cde..5c426a9e 100644 --- a/apps/landing-page/next.config.ts +++ b/apps/landing-page/next.config.ts @@ -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). ], }, ];