Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions services/platform/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<SITE_URL>/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', () => {
Expand Down
33 changes: 31 additions & 2 deletions services/platform/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<SITE_URL>/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 `<SITE_URL>/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: [
Expand All @@ -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'"],
Expand Down