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.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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("; ");
|
||||
}
|
||||
Reference in New Issue
Block a user