diff --git a/services/platform/server.test.ts b/services/platform/server.test.ts index aa1c2fc13..a3776546f 100644 --- a/services/platform/server.test.ts +++ b/services/platform/server.test.ts @@ -131,6 +131,41 @@ describe('security headers', () => { const csp = res.headers.get('content-security-policy') ?? ''; expect(csp).toContain('https://sentry.elintrio.com'); }); + + // Regression guard for issue #1964 — custom branding favicons and fonts are + // addressed as absolute `/branding/...` URLs, so when the app is + // reached from a host other than SITE_URL they're cross-origin and were + // blocked by `img-src`/`font-src 'self'`. The canonical SITE_URL origin + // (the operator's own, never a third-party CDN) must appear in both. + test('CSP allows branding assets from the SITE_URL origin (issue #1964)', async () => { + const app = createApp({ + ...baseEnv, + SITE_URL: 'https://brand.example.com', + }); + const res = await app.fetch(new Request('http://localhost/api/health')); + const csp = res.headers.get('content-security-policy') ?? ''; + const directive = (name: string) => + csp + .split(';') + .map((d) => d.trim()) + .find((d) => d.startsWith(`${name} `)) ?? ''; + expect(directive('img-src')).toContain('https://brand.example.com'); + expect(directive('font-src')).toContain('https://brand.example.com'); + // Strictness is preserved: the origin is added without widening to a + // wildcard or `unsafe-inline`. + expect(directive('font-src')).not.toContain('*'); + }); + + // Only the origin (scheme + host + port) is allow-listed, never the path — + // a CSP source is an origin, and leaking the `/branding/...` path here would + // be both invalid and over-specific. + test('CSP omits the branding origin when SITE_URL is unset', async () => { + const app = createApp({ ...baseEnv, SITE_URL: '' }); + const res = await app.fetch(new Request('http://localhost/api/health')); + const csp = res.headers.get('content-security-policy') ?? ''; + // Same-origin assets are covered by `'self'`; no extra origin is emitted. + expect(csp).toContain("font-src 'self' data:"); + }); }); describe('POST /canvas-preview', () => { diff --git a/services/platform/server.ts b/services/platform/server.ts index 196909e45..bd9abf758 100644 --- a/services/platform/server.ts +++ b/services/platform/server.ts @@ -369,10 +369,39 @@ function sentryOriginFromDsn(dsn: string | undefined): string | null { } } +// Canonical origin the platform builds its own absolute asset URLs from. +// Branding assets (custom favicons, logos, and any branding-served font) are +// addressed by `buildBrandingImageUrl` as `/branding/...`, i.e. an +// absolute URL pinned to the canonical SITE_URL. When the app is reached from +// a host that differs from SITE_URL (reverse proxy, custom domain, www/apex +// split), those assets are cross-origin to the document and `'self'` no longer +// matches — so they're blocked by `img-src`/`font-src 'self'`. Locally +// SITE_URL is usually unset, the URLs are relative, and everything is +// same-origin, which is why this only bites in production. This is the +// operator's OWN origin (never a third-party CDN), so allow-listing it keeps +// the policy strict and needs no third-party data-transfer review. +function siteOriginFromUrl(siteUrl: string | undefined): string | null { + if (!siteUrl) return null; + try { + const url = new URL(siteUrl); + return url.origin; + } catch (err) { + console.warn('Invalid SITE_URL, skipping CSP allow-list entry:', err); + return null; + } +} + function buildContentSecurityPolicy(env: EnvConfig) { const sentryOrigin = sentryOriginFromDsn(env.SENTRY_DSN); const sentry = sentryOrigin ? [sentryOrigin] : []; const figmaMcp = isLoopbackSite(env) ? ['https://mcp.figma.com'] : []; + // The platform's own canonical origin, so branding assets served as + // absolute `/branding/...` URLs load even when the document is + // reached from a different host than SITE_URL. Omitted (empty) when + // SITE_URL is unset/relative — then assets are same-origin and `'self'` + // already covers them. + const siteOrigin = siteOriginFromUrl(env.SITE_URL); + const branding = siteOrigin ? [siteOrigin] : []; return { defaultSrc: ["'self'"], scriptSrc: [ @@ -386,8 +415,8 @@ function buildContentSecurityPolicy(env: EnvConfig) { ...figmaMcp, ], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:', 'blob:'], - fontSrc: ["'self'", 'data:'], + imgSrc: ["'self'", 'data:', 'blob:', ...branding], + fontSrc: ["'self'", 'data:', ...branding], connectSrc: ["'self'", ...sentry], workerSrc: ["'self'", 'blob:'], frameSrc: ["'self'"],