/** * Content-Security-Policy builders for the SvelteKit app. * Kept free of $app/$env imports so unit tests can run under node:test. */ import { resolveGtmId } from "../analytics/gtm-id.ts"; import { alignLoopbackApiBase } from "../loopback-api.ts"; export type ContentSecurityPolicyOptions = { /** Vite HMR needs 'unsafe-eval' and websocket connect-src. */ dev: boolean; /** Absolute API origin when the SPA talks cross-origin (empty PUBLIC_API_URL = same-origin). */ apiOrigin?: string | null; /** Raw PUBLIC_GTM_ID - Google hosts are allowlisted only when this resolves to a valid GTM id. */ gtmId?: string | null; }; /** Parse PUBLIC_API_URL into an origin, omitting same-origin / empty / invalid values. */ export function resolveApiOrigin(apiUrl: string, pageOrigin: string): string | null { const trimmed = apiUrl.trim().replace(/\/$/, ""); if (!trimmed) return null; try { const parsed = new URL(trimmed); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return null; } let pageHost = ""; try { pageHost = new URL(pageOrigin).hostname; } catch { pageHost = ""; } const origin = pageHost ? alignLoopbackApiBase(parsed.origin, pageHost) : parsed.origin; if (origin === pageOrigin) return null; return origin; } catch { return null; } } /** * Build an enforced Content-Security-Policy for HTML responses. * Development relaxes script-src/connect-src for Vite HMR; production stays tighter. * /docs uses vendored RapiDoc (same-origin only) — no third-party docs CDN connect-src. */ /** Hosts required for Google Tag Manager + GA4 (only when a valid PUBLIC_GTM_ID is configured). */ const GTM_SCRIPT_SRC = ["https://www.googletagmanager.com"]; const GTM_IMG_SRC = [ "https://www.googletagmanager.com", "https://www.google-analytics.com" ]; const GTM_CONNECT_SRC = [ "https://www.googletagmanager.com", "https://www.google-analytics.com", "https://analytics.google.com", "https://region1.google-analytics.com", "https://stats.g.doubleclick.net" ]; const GTM_FRAME_SRC = ["https://www.googletagmanager.com"]; function joinSrc(base: string, hosts: string[]): string { return hosts.length > 0 ? `${base} ${hosts.join(" ")}` : base; } export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): string { const gtmEnabled = resolveGtmId(opts.gtmId) !== null; const gtmScript = gtmEnabled ? GTM_SCRIPT_SRC : []; const gtmImg = gtmEnabled ? GTM_IMG_SRC : []; const gtmConnect = gtmEnabled ? GTM_CONNECT_SRC : []; const gtmFrame = gtmEnabled ? GTM_FRAME_SRC : []; // blob: — /docs loads OpenAPI YAML via a short-lived blob URL into RapiDoc. const connect = ["'self'", "blob:", ...gtmConnect]; if (opts.apiOrigin) { connect.push(opts.apiOrigin); } const fontSrc = "font-src 'self' data:"; const imgSrc = joinSrc("img-src 'self' data: blob:", gtmImg); const frameSrc = joinSrc("frame-src 'self'", gtmFrame); if (opts.dev) { // Vite HMR: eval for the client, ws/wss (+ loopback HTTP) for the overlay socket. connect.push("ws:", "wss:", "http://localhost:*", "http://127.0.0.1:*"); return [ "default-src 'self'", "base-uri 'self'", "frame-ancestors 'none'", "object-src 'none'", joinSrc("script-src 'self' 'unsafe-inline' 'unsafe-eval'", gtmScript), "style-src 'self' 'unsafe-inline'", imgSrc, fontSrc, frameSrc, `connect-src ${connect.join(" ")}`, "form-action 'self'" ].join("; "); } return [ "default-src 'self'", "base-uri 'self'", "frame-ancestors 'none'", "object-src 'none'", // 'unsafe-inline': marketing FOUC guard in app.html (inline script). joinSrc("script-src 'self' 'unsafe-inline'", gtmScript), "style-src 'self' 'unsafe-inline'", imgSrc, fontSrc, frameSrc, `connect-src ${connect.join(" ")}`, "form-action 'self'" ].join("; "); }