function normalizeAssetPrefix(rawValue, basePath) { if (!rawValue) return undefined try { const url = new URL(rawValue) const pathname = url.pathname.replace(/\/$/, '') if (!pathname.endsWith(basePath)) { url.pathname = `${pathname}${basePath}` } return url.toString().replace(/\/$/, '') } catch { const trimmed = rawValue.replace(/\/$/, '') return trimmed.endsWith(basePath) ? trimmed : `${trimmed}${basePath}` } } function collectOrigins(assetSources) { const origins = new Set() for (const source of assetSources) { if (!source) continue try { origins.add(new URL(source).origin) } catch { continue } } return [...origins] } function collectWebSocketOrigins(assetSources) { const origins = new Set() for (const source of assetSources) { if (!source) continue try { const url = new URL(source) const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' origins.add(`${protocol}//${url.host}`) } catch { continue } } return [...origins] } function collectBrowserApiSources() { const sources = [process.env.NEXT_PUBLIC_API_URL, process.env.API_URL] if (process.env.NODE_ENV !== 'production') { sources.push('http://localhost:4000/api/v1') } return sources } function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) { const assetOrigins = collectOrigins(assetSources) const connectOrigins = collectOrigins(connectSources) const websocketOrigins = [ ...collectWebSocketOrigins(assetSources), ...collectWebSocketOrigins(connectSources), ] const scriptSrc = ["'self'", "'unsafe-inline'"] if (process.env.NODE_ENV !== 'production') { scriptSrc.push("'unsafe-eval'") } scriptSrc.push(...assetOrigins) const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins] const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])] const fontSrc = ["'self'", 'data:', ...assetOrigins] const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] return [ { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, { 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=()' }, { key: 'Content-Security-Policy', value: [ "default-src 'self'", `script-src ${scriptSrc.join(' ')}`, `style-src ${styleSrc.join(' ')}`, `img-src ${imgSrc.join(' ')}`, `font-src ${fontSrc.join(' ')}`, `connect-src ${connectSrc.join(' ')}`, "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join('; '), }, ] } module.exports = { buildSecurityHeaders, normalizeAssetPrefix, }