From 07f7b6fb02d934b269d4c265f1654a0002ffc1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Fri, 10 Apr 2026 20:56:33 +0300 Subject: [PATCH] feat(security): enhance Content Security Policy and add global security headers --- next.config.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/next.config.ts b/next.config.ts index bcd6c5e..d0c639a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,64 @@ function resolveBunnyCdnHostname(): string | null { } const bunnyCdnHostname = resolveBunnyCdnHostname(); +const isDev = process.env.NODE_ENV === 'development'; + +// Build Content-Security-Policy from resolved config +const cdnOrigin = bunnyCdnHostname ? `https://${bunnyCdnHostname}` : ''; + +const connectSrcParts = [ + "'self'", + 'https://video.bunnycdn.com', + cdnOrigin, + // Allow Next.js HMR websocket in development + ...(isDev ? ['ws://localhost:* wss://localhost:*'] : []), +].filter(Boolean); + +const imgSrcParts = [ + "'self'", + 'data:', + 'blob:', + 'https://img.youtube.com', + 'https://i.ytimg.com', + 'https://images.unsplash.com', + 'https://vz-thumbnail.b-cdn.net', + cdnOrigin, +].filter(Boolean); + +const mediaSrcParts = ["'self'", 'blob:', cdnOrigin].filter(Boolean); + +const contentSecurityPolicy = [ + "default-src 'self'", + // 'unsafe-inline' is required by Next.js App Router (hydration scripts, inline styles) + // https://www.youtube.com is required for the dynamically-injected YouTube IFrame API script + "script-src 'self' 'unsafe-inline' https://www.youtube.com", + "style-src 'self' 'unsafe-inline'", + `img-src ${imgSrcParts.join(' ')}`, + `media-src ${mediaSrcParts.join(' ')}`, + "frame-src 'self' https://www.youtube.com https://iframe.mediadelivery.net", + `connect-src ${connectSrcParts.join(' ')}`, + // next/font self-hosts Google Fonts at build time — no external font origin needed + "font-src 'self'", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", +].join('; '); + +const securityHeaders = [ + { key: 'Content-Security-Policy', value: contentSecurityPolicy }, + // Prevent the app from being embedded in foreign iframes (clickjacking) + { key: 'X-Frame-Options', value: 'DENY' }, + // Prevent MIME-type sniffing on all responses + { key: 'X-Content-Type-Options', value: 'nosniff' }, + // Enforce HTTPS for 2 years + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' }, + // microphone=(self) is required for audio comment recording; camera/geolocation unused + { key: 'Permissions-Policy', value: 'camera=(), microphone=(self), geolocation=()' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, +]; + const remotePatterns: RemotePattern[] = [ { protocol: 'https', hostname: 'img.youtube.com' }, { protocol: 'https', hostname: 'i.ytimg.com' }, @@ -34,6 +92,11 @@ const nextConfig: NextConfig = { compress: true, async headers() { return [ + // Global security headers applied to every response + { + source: '/(.*)', + headers: securityHeaders, + }, { source: '/_next/static/:path*', headers: [