clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix/175-template-quality-pass
lofi / package / tooling / csp.ts
csp.ts 3.3K
1/**2 * Content-Security-Policy tooling for the build: parse the per-page meta3 * policies Astro emits, union them into one deployable policy (hash unions4 * are additive allowances, so a single merged policy is valid for every5 * page), and report weakenings. The build reports and never gates — which6 * directives an app ships is the author's call; only the information is7 * mandatory.8 *9 * @module10 */1112const META_PATTERN = /<meta\s+http-equiv="content-security-policy"\s+content="([^"]*)"\s*\/?>/i;1314/** The CSP policy of one built page, or null when the page carries none. */15export function parseCspMeta(html: string): string | null {16 const match = html.match(META_PATTERN);17 if (!match) return null;18 return match[1].replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&amp;", "&")19 .trim();20}2122function parsePolicy(policy: string): Map<string, Set<string>> {23 const directives = new Map<string, Set<string>>();24 for (const entry of policy.split(";")) {25 const trimmed = entry.trim();26 if (!trimmed) continue;27 const [name, ...values] = trimmed.split(/\s+/);28 const existing = directives.get(name) ?? new Set<string>();29 for (const value of values) existing.add(value);30 directives.set(name, existing);31 }32 return directives;33}3435/** Unions per-page policies into one policy valid for every page. */36export function mergeCspPolicies(policies: readonly string[]): string {37 const merged = new Map<string, Set<string>>();38 for (const policy of policies) {39 for (const [name, values] of parsePolicy(policy)) {40 const existing = merged.get(name) ?? new Set<string>();41 for (const value of values) existing.add(value);42 merged.set(name, existing);43 }44 }45 return [...merged.entries()]46 .map(([name, values]) => [name, ...values].join(" "))47 .join("; ");48}4950/** Findings worth surfacing about a policy; empty means nothing to report. */51export function cspPolicyWarnings(policy: string): string[] {52 const warnings: string[] = [];53 const directives = parsePolicy(policy);54 const scripts = directives.get("script-src") ?? new Set<string>();55 if (scripts.has("'unsafe-inline'")) {56 warnings.push("script-src allows 'unsafe-inline', which disables the injection protection");57 }58 if (scripts.has("'unsafe-eval'")) {59 warnings.push("script-src allows 'unsafe-eval'");60 }61 const remote = [...scripts].filter((value) =>62 !value.startsWith("'") && value !== "blob:" && value !== "data:"63 );64 if (remote.length > 0) {65 warnings.push(`script-src admits remote origins: ${remote.join(", ")}`);66 }67 return warnings;68}6970/**71 * A commented header snippet for hosts that support response headers. The72 * meta tag already enforces the policy everywhere; a real header adds the73 * directives a meta tag cannot carry (frame-ancestors) and governs the74 * service worker's own execution.75 */76export function renderHeadersSnippet(policy: string): string {77 return [78 "# Content-Security-Policy for hosts that support response headers.",79 "# The built pages already enforce this policy via a meta tag; a real",80 "# header additionally covers frame-ancestors (ignored in meta by spec)",81 "# and the service worker script itself (sw.js). Adapt the syntax to",82 "# your host (this file uses the Netlify/Cloudflare _headers format).",83 "/*",84 ` Content-Security-Policy: ${policy}; frame-ancestors 'none'`,85 "",86 ].join("\n");87}