fix(csp): add missing analytics domains, remove unsafe-eval, fix workspace CSP gap (#4179)

This commit is contained in:
Waleed
2026-04-15 10:04:53 -07:00
committed by GitHub
parent ff71a07e8f
commit 842aa2c254
3 changed files with 135 additions and 105 deletions
+123 -101
View File
@@ -29,38 +29,112 @@ export interface CSPDirectives {
'object-src'?: string[]
}
/**
* Static CSP sources shared between build-time and runtime.
* Add new domains here — both paths pick them up automatically.
*/
const STATIC_SCRIPT_SRC = [
"'self'",
"'unsafe-inline'",
'https://*.google.com',
'https://apis.google.com',
'https://assets.onedollarstats.com',
'https://challenges.cloudflare.com',
...(isReactGrabEnabled ? ['https://unpkg.com'] : []),
...(isHosted
? [
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
'https://analytics.ahrefs.com',
]
: []),
] as const
const STATIC_IMG_SRC = [
"'self'",
'data:',
'blob:',
'https://*.googleusercontent.com',
'https://*.google.com',
'https://*.atlassian.com',
'https://cdn.discordapp.com',
'https://*.githubusercontent.com',
'https://*.s3.amazonaws.com',
'https://s3.amazonaws.com',
'https://*.amazonaws.com',
'https://*.blob.core.windows.net',
'https://github.com/*',
'https://collector.onedollarstats.com',
'https://cursor.com',
...(isHosted ? ['https://www.googletagmanager.com', 'https://www.google-analytics.com'] : []),
] as const
const STATIC_CONNECT_SRC = [
"'self'",
'https://api.browser-use.com',
'https://api.elevenlabs.io',
'wss://api.elevenlabs.io',
'https://api.exa.ai',
'https://api.firecrawl.dev',
'https://*.googleapis.com',
'https://*.amazonaws.com',
'https://*.s3.amazonaws.com',
'https://*.blob.core.windows.net',
'https://*.atlassian.com',
'https://*.supabase.co',
'https://api.github.com',
'https://github.com/*',
'https://challenges.cloudflare.com',
'https://collector.onedollarstats.com',
...(isHosted
? [
'https://www.googletagmanager.com',
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://analytics.google.com',
'https://www.google.com',
]
: []),
] as const
const STATIC_FRAME_SRC = [
"'self'",
'https://challenges.cloudflare.com',
'https://drive.google.com',
'https://docs.google.com',
'https://*.google.com',
'https://www.youtube.com',
'https://player.vimeo.com',
'https://www.dailymotion.com',
'https://player.twitch.tv',
'https://clips.twitch.tv',
'https://streamable.com',
'https://fast.wistia.net',
'https://www.tiktok.com',
'https://w.soundcloud.com',
'https://open.spotify.com',
'https://embed.music.apple.com',
'https://www.loom.com',
'https://www.facebook.com',
'https://www.instagram.com',
'https://platform.twitter.com',
'https://rumble.com',
'https://play.vidyard.com',
'https://iframe.cloudflarestream.com',
'https://www.mixcloud.com',
'https://tenor.com',
'https://giphy.com',
...(isHosted ? ['https://www.googletagmanager.com'] : []),
] as const
// Build-time CSP directives (for next.config.ts)
export const buildTimeCSPDirectives: CSPDirectives = {
'default-src': ["'self'"],
'script-src': [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
'https://*.google.com',
'https://apis.google.com',
'https://assets.onedollarstats.com',
'https://challenges.cloudflare.com',
...(isReactGrabEnabled ? ['https://unpkg.com'] : []),
...(isHosted ? ['https://www.googletagmanager.com', 'https://www.google-analytics.com'] : []),
],
'script-src': [...STATIC_SCRIPT_SRC],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'img-src': [
"'self'",
'data:',
'blob:',
'https://*.googleusercontent.com',
'https://*.google.com',
'https://*.atlassian.com',
'https://cdn.discordapp.com',
'https://*.githubusercontent.com',
'https://*.s3.amazonaws.com',
'https://s3.amazonaws.com',
'https://github.com/*',
'https://collector.onedollarstats.com',
...(isHosted ? ['https://www.googletagmanager.com', 'https://www.google-analytics.com'] : []),
...STATIC_IMG_SRC,
...(env.S3_BUCKET_NAME && env.AWS_REGION
? [`https://${env.S3_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`]
: []),
@@ -70,21 +144,16 @@ export const buildTimeCSPDirectives: CSPDirectives = {
...(env.S3_CHAT_BUCKET_NAME && env.AWS_REGION
? [`https://${env.S3_CHAT_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`]
: []),
'https://*.amazonaws.com',
'https://*.blob.core.windows.net',
'https://github.com/*',
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_FAVICON_URL),
],
'media-src': ["'self'", 'blob:'],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'connect-src': [
"'self'",
...STATIC_CONNECT_SRC,
env.NEXT_PUBLIC_APP_URL || '',
// Only include localhost fallbacks in development mode
...(env.OLLAMA_URL ? [env.OLLAMA_URL] : isDev ? ['http://localhost:11434'] : []),
...(env.NEXT_PUBLIC_SOCKET_URL
? [
@@ -94,42 +163,12 @@ export const buildTimeCSPDirectives: CSPDirectives = {
: isDev
? ['http://localhost:3002', 'ws://localhost:3002']
: []),
'https://api.browser-use.com',
'https://api.elevenlabs.io',
'wss://api.elevenlabs.io',
'https://api.exa.ai',
'https://api.firecrawl.dev',
'https://*.googleapis.com',
'https://*.amazonaws.com',
'https://*.s3.amazonaws.com',
'https://*.blob.core.windows.net',
'https://*.atlassian.com',
'https://*.supabase.co',
'https://api.github.com',
'https://github.com/*',
'https://challenges.cloudflare.com',
'https://collector.onedollarstats.com',
...(isHosted
? [
'https://www.googletagmanager.com',
'https://*.google-analytics.com',
'https://*.analytics.google.com',
]
: []),
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
...getHostnameFromUrl(env.NEXT_PUBLIC_TERMS_URL),
],
'frame-src': [
"'self'",
'https://challenges.cloudflare.com',
'https://drive.google.com',
'https://docs.google.com',
'https://*.google.com',
...(isHosted ? ['https://www.googletagmanager.com'] : []),
],
'frame-src': [...STATIC_FRAME_SRC],
'frame-ancestors': ["'self'"],
'form-action': ["'self'"],
'base-uri': ["'self'"],
@@ -152,13 +191,14 @@ export function buildCSPString(directives: CSPDirectives): string {
}
/**
* Generate runtime CSP header with dynamic environment variables (safer approach)
* This maintains compatibility with existing inline scripts while fixing Docker env var issues
* Generate runtime CSP header with dynamic environment variables.
* Composes from the same STATIC_* constants as buildTimeCSPDirectives,
* but resolves env vars at request time via getEnv() to fix Docker
* deployments where build-time values may be stale placeholders.
*/
export function generateRuntimeCSP(): string {
const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || ''
// Only include localhost URLs in development or when explicitly configured
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? 'http://localhost:3002' : '')
const socketWsUrl = socketUrl
? socketUrl.replace('http://', 'ws://').replace('https://', 'wss://')
@@ -172,42 +212,24 @@ export function generateRuntimeCSP(): string {
const privacyDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_PRIVACY_URL'))
const termsDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_TERMS_URL'))
const allDynamicDomains = [
...brandLogoDomains,
...brandFaviconDomains,
...privacyDomains,
...termsDomains,
]
const uniqueDynamicDomains = Array.from(new Set(allDynamicDomains))
const dynamicDomainsStr = uniqueDynamicDomains.join(' ')
const brandLogoDomain = brandLogoDomains[0] || ''
const brandFaviconDomain = brandFaviconDomains[0] || ''
const reactGrabScript = isReactGrabEnabled ? 'https://unpkg.com' : ''
const gtmScript = isHosted
? 'https://www.googletagmanager.com https://www.google-analytics.com'
: ''
const gtmConnect = isHosted
? 'https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com'
: ''
const gtmImg = isHosted ? 'https://www.googletagmanager.com https://www.google-analytics.com' : ''
const gtmFrame = isHosted ? 'https://www.googletagmanager.com' : ''
const runtimeDirectives: CSPDirectives = {
...buildTimeCSPDirectives,
return `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com https://challenges.cloudflare.com ${reactGrabScript} ${gtmScript};
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.s3.amazonaws.com https://s3.amazonaws.com https://*.amazonaws.com https://*.blob.core.windows.net https://github.com/* https://collector.onedollarstats.com ${gtmImg} ${brandLogoDomain} ${brandFaviconDomain};
media-src 'self' blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.elevenlabs.io wss://api.elevenlabs.io https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://challenges.cloudflare.com https://collector.onedollarstats.com ${gtmConnect} ${dynamicDomainsStr};
frame-src 'self' https://challenges.cloudflare.com https://drive.google.com https://docs.google.com https://*.google.com ${gtmFrame};
frame-ancestors 'self';
form-action 'self';
base-uri 'self';
object-src 'none';
`
.replace(/\s{2,}/g, ' ')
.trim()
'img-src': [...STATIC_IMG_SRC, ...brandLogoDomains, ...brandFaviconDomains],
'connect-src': [
...STATIC_CONNECT_SRC,
appUrl,
ollamaUrl,
socketUrl,
socketWsUrl,
...brandLogoDomains,
...privacyDomains,
...termsDomains,
],
}
return buildCSPString(runtimeDirectives)
}
/**
+2 -2
View File
@@ -338,10 +338,10 @@ const nextConfig: NextConfig = {
],
},
// Apply security headers to routes not handled by middleware runtime CSP
// Middleware handles: /, /workspace/*
// Middleware handles: /, /login, /signup, /workspace/*
// Exclude chat and form routes which have their own permissive embed headers
{
source: '/((?!workspace|chat|form).*)',
source: '/((?!workspace|chat|form|login|signup|$).*)',
headers: [
{
key: 'X-Content-Type-Options',
+10 -2
View File
@@ -154,6 +154,8 @@ export async function proxy(request: NextRequest) {
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return track(request, response)
}
@@ -176,7 +178,11 @@ export async function proxy(request: NextRequest) {
if (!hasActiveSession) {
return track(request, NextResponse.redirect(new URL('/login', request.url)))
}
return track(request, NextResponse.next())
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
return track(request, response)
}
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
@@ -191,8 +197,10 @@ export async function proxy(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
if (url.pathname.startsWith('/workspace') || url.pathname === '/') {
if (url.pathname === '/') {
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
}
return track(request, response)