diff --git a/__tests__/next-config.test.ts b/__tests__/next-config.test.ts new file mode 100644 index 0000000..0b3eb78 --- /dev/null +++ b/__tests__/next-config.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { nextConfig, securityHeaders, cspHeader } from '../next.config.mjs' + +describe('next.config security headers & CSP', () => { + it('defines headers function in nextConfig', async () => { + expect(nextConfig.headers).toBeTypeOf('function') + const headersConfig = await nextConfig.headers!() + expect(headersConfig).toHaveLength(1) + expect(headersConfig[0].source).toBe('/:path*') + expect(headersConfig[0].headers).toEqual(securityHeaders) + }) + + it('configures all required security headers', () => { + const headerMap = Object.fromEntries( + securityHeaders.map((h) => [h.key, h.value]) + ) + + expect(headerMap['X-Content-Type-Options']).toBe('nosniff') + expect(headerMap['X-Frame-Options']).toBe('DENY') + expect(headerMap['Referrer-Policy']).toBe('strict-origin-when-cross-origin') + expect(headerMap['Permissions-Policy']).toBe('camera=(), microphone=(), geolocation=()') + expect(headerMap['Content-Security-Policy']).toBeDefined() + }) + + it('configures CSP with required directives and Stellar RPC support', () => { + expect(cspHeader).toContain("default-src 'self'") + expect(cspHeader).toContain("object-src 'none'") + expect(cspHeader).toContain("frame-ancestors 'none'") + expect(cspHeader).toContain("base-uri 'self'") + + // Supports Tailwind / shadcn inline styles + expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'") + + // Supports Stellar Horizon & Soroban RPCs and stellar.expert price feeds + expect(cspHeader).toContain('connect-src') + expect(cspHeader).toContain('https://*.stellar.org') + expect(cspHeader).toContain('https://horizon.stellar.org') + expect(cspHeader).toContain('https://horizon-testnet.stellar.org') + expect(cspHeader).toContain('https://soroban-testnet.stellar.org') + expect(cspHeader).toContain('https://api.stellar.expert') + }) +}) diff --git a/next.config.mjs b/next.config.mjs index dd14406..2bf6bd6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,52 @@ import { withSentryConfig } from '@sentry/nextjs' +export const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-eval' 'unsafe-inline'; + style-src 'self' 'unsafe-inline'; + img-src 'self' blob: data: https:; + font-src 'self' data:; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + connect-src 'self' https://*.stellar.org https://horizon.stellar.org https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org https://api.stellar.expert https://*.sentry.io; +`.replace(/\s{2,}/g, ' ').trim() + +export const securityHeaders = [ + { + key: 'Content-Security-Policy', + value: cspHeader, + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, +] + /** @type {import('next').NextConfig} */ -const nextConfig = {} +export const nextConfig = { + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ] + }, +} // Bundle analyzer — run with: ANALYZE=true npm run build async function withBundleAnalyzer(config) {