Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
115 lines
3.7 KiB
TypeScript
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("; ");
|
|
}
|