/** * Single source of truth for the Capgo console Content-Security-Policy. * * Sync into `public/_headers` with: * bun run security:sync-headers */ import configs from '../configs.json' export interface ConsoleCspOptions { /** Local Vite dev server — allow http/ws to localhost. */ dev?: boolean } function joinSources(sources: string[]) { return [...new Set(sources)].join(' ') } function addConnectOrigin(sources: Set, raw: string) { if (!raw) return if (raw.includes('://')) { try { const url = new URL(raw) sources.add(url.origin) if (url.protocol === 'https:') sources.add(`wss://${url.host}`) else if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) sources.add(`ws://${url.host}`) } catch { // Ignore invalid configured URLs. } return } const host = raw.split('/')[0] if (host.includes('localhost') || host.startsWith('127.')) { sources.add(`http://${host}`) sources.add(`ws://${host}`) } else { sources.add(`https://${host}`) } } function getDeployTimeConnectOverrides(): string[] { const overrides: string[] = [] const apiDomain = process.env.API_DOMAIN if (typeof apiDomain === 'string' && apiDomain) overrides.push(apiDomain) return overrides } function getConfiguredConnectSources(dev: boolean): string[] { const sources = new Set() const envKeys = dev ? (['prod', 'preprod', 'development', 'local'] as const) : (['prod'] as const) for (const env of envKeys) { const apiDomain = configs.api_domain?.[env] if (typeof apiDomain === 'string') addConnectOrigin(sources, apiDomain) } for (const raw of getDeployTimeConnectOverrides()) addConnectOrigin(sources, raw) return [...sources] } const PREVIEW_CONNECT_SOURCES = [ 'https://*.preview.capgo.app', 'https://*.preview.preprod.capgo.app', 'https://*.preview.dev.capgo.app', 'https://*.preview.development.capgo.app', ] export function buildConsoleContentSecurityPolicy(options: ConsoleCspOptions = {}): string { const dev = options.dev === true const scriptSrc = joinSources([ '\'self\'', 'https://challenges.cloudflare.com', // Cloudflare Turnstile 'https://static.cloudflareinsights.com', // Cloudflare Web Analytics (if enabled) 'https://psthg.capgo.app', // first-party PostHog reverse proxy 'https://js.stripe.com', // Stripe.js for embedded Checkout / Payment Element 'https://*.js.stripe.com', ]) const styleSrc = joinSources([ '\'self\'', '\'unsafe-inline\'', // Vue / Tailwind runtime styles ]) const fontSrc = joinSources([ '\'self\'', 'data:', ]) const imgSrc = joinSources([ '\'self\'', 'data:', 'blob:', 'https:', // org logos, avatars, store icons, signed storage URLs ]) const mediaSrc = joinSources([ '\'self\'', 'data:', 'blob:', 'https:', ]) const connectSrc = joinSources([ '\'self\'', 'blob:', 'https://psthg.capgo.app', 'https://eu.posthog.com', 'https://eu.i.posthog.com', 'https://challenges.cloudflare.com', 'https://api.github.com', 'https://registry.npmjs.org', 'https://api.stripe.com', // Stripe.js API calls 'https://checkout.stripe.com', // embedded Checkout 'https://merchant-ui-api.stripe.com', 'https://hooks.stripe.com', 'https://auth.openai.com', // assistant ChatGPT sign-in token exchange 'https://api.openai.com', // assistant model calls ...PREVIEW_CONNECT_SOURCES, ...getConfiguredConnectSources(dev), ...(dev ? [ 'http://localhost:*', 'http://127.0.0.1:*', 'ws://localhost:*', 'ws://127.0.0.1:*', ] : []), ]) const frameSrc = joinSources([ '\'self\'', 'https://challenges.cloudflare.com', // Turnstile widget 'https://checkout.stripe.com', 'https://billing.stripe.com', 'https://js.stripe.com', 'https://*.js.stripe.com', 'https://hooks.stripe.com', // 3DS challenge frames ...PREVIEW_CONNECT_SOURCES, ]) const directives = [ 'default-src \'self\'', 'base-uri \'self\'', 'object-src \'none\'', 'frame-ancestors \'none\'', 'form-action \'self\' https://checkout.stripe.com https://billing.stripe.com', `script-src ${scriptSrc}`, `style-src ${styleSrc}`, `img-src ${imgSrc}`, `font-src ${fontSrc}`, `connect-src ${connectSrc}`, `media-src ${mediaSrc}`, `frame-src ${frameSrc}`, 'worker-src \'self\' blob:', 'manifest-src \'self\'', ] if (!dev) directives.push('upgrade-insecure-requests') return directives.join('; ') } export const CONSOLE_CONTENT_SECURITY_POLICY = buildConsoleContentSecurityPolicy()