Files
descrybe/apps/web/src/lib/server/csp.ts
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

115 lines
3.7 KiB
TypeScript

/**
* 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("; ");
}