UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

114 lines (113 loc) • 6.78 kB
import { partitionByKind } from "../../../core/csp/runtime.js"; export function renderCspContent(result) { const { scriptDirective, styleDirective, directives } = result; // `kind` is interpreted here (the single partition point): each directive's entries are grouped // into the generic `script-src`/`style-src` and the more specific `-elem`/`-attr` variants. const script = partitionByKind(scriptDirective); const style = partitionByKind(styleDirective); // `default`-kind hashes (Astro's generated element hashes + user `default` hashes + render-time // generated hashes), quoted once. These are emitted on whichever directive governs elements. const finalScriptHashes = new Set(); for (const scriptHash of script.default.hashes) { finalScriptHashes.add(`'${scriptHash}'`); } for (const scriptHash of result._metadata.extraScriptHashes) { finalScriptHashes.add(`'${scriptHash}'`); } const finalStyleHashes = new Set(); for (const styleHash of style.default.hashes) { finalStyleHashes.add(`'${styleHash}'`); } for (const styleHash of result._metadata.extraStyleHashes) { finalStyleHashes.add(`'${styleHash}'`); } let directivesContent; if (directives.length > 0) { directivesContent = directives.join(";") + ";"; } const scriptResources = script.default.resources.length > 0 ? script.default.resources.join(" ") : "'self'"; const styleResources = style.default.resources.length > 0 ? style.default.resources.join(" ") : "'self'"; const scriptElementDefaultResource = script.default.resources.length > 0 ? "" : "'self'"; const styleElementDefaultResource = style.default.resources.length > 0 ? "" : "'self'"; // `script-src`/`style-src` cover everything; the `-elem`/`-attr` variants are narrower: // - `*-src-elem` → tags (`<script>`, `<style>`, `<link rel="stylesheet">`) // - `*-src-attr` → inline attributes (`onclick`, `style`) // When a narrower directive is present, the browser uses only it for that scope and does not fall // back to `script-src`/`style-src`. // // Default hashes are element-content hashes, so they're emitted on whichever directive governs // elements: `script-src-elem`/`style-src-elem` when it's in use, otherwise the baseline // `script-src`/`style-src`. They aren't duplicated on the baseline, because the browser never // falls back from a narrower directive to it. // // Default sources, by contrast, stay exactly where they are scoped: a source is a broad, // per-scope allowance, so moving or copying it would change the policy intended for another // scope. The `-attr` directives receive only what is explicitly scoped to them. `strict-dynamic` // from `script-src` also applies to `script-src-elem`. A narrower directive is emitted only when // it has its own entries. const scriptElemActive = isEnabled(script.element); const styleElemActive = isEnabled(style.element); const strictDynamicSuffix = scriptDirective.strictDynamic ? ` 'strict-dynamic'` : ""; // Per the CSP spec, browsers ignore `'unsafe-inline'` when a hash or nonce is present in the // same directive. Suppress hashes on any directive whose resources include `'unsafe-inline'` so // the user's intent is preserved. const scriptDefaultHasUnsafeInline = hasUnsafeInline(script.default.resources); const styleDefaultHasUnsafeInline = hasUnsafeInline(style.default.resources); // On the baseline, omit the element hashes when the `-elem` directive will carry them instead, // and omit all hashes when `'unsafe-inline'` is present. const scriptBaselineTokens = [...scriptElemActive || scriptDefaultHasUnsafeInline ? [] : [...finalScriptHashes], ...scriptDirective.strictDynamic ? [`'strict-dynamic'`] : []]; const scriptSrc = `script-src ${scriptResources} ${scriptBaselineTokens.join(" ")};`; const styleSrc = `style-src ${styleResources} ${(styleElemActive || styleDefaultHasUnsafeInline ? [] : [...finalStyleHashes]).join(" ")};`; const scriptSrcElem = scriptElemActive ? renderSpecificDirective("script-src-elem", script.element.resources, scriptElementDefaultResource, finalScriptHashes, script.element.hashes, strictDynamicSuffix) : undefined; const scriptSrcAttr = isEnabled(script.attribute) ? renderSpecificDirective("script-src-attr", script.attribute.resources, "'none'", undefined, script.attribute.hashes) : undefined; const styleSrcElem = styleElemActive ? renderSpecificDirective("style-src-elem", style.element.resources, styleElementDefaultResource, finalStyleHashes, style.element.hashes) : undefined; const styleSrcAttr = isEnabled(style.attribute) ? renderSpecificDirective("style-src-attr", style.attribute.resources, "'none'", undefined, style.attribute.hashes) : undefined; return [ directivesContent, scriptSrc, scriptSrcElem, scriptSrcAttr, styleSrc, styleSrcElem, styleSrcAttr ].filter(Boolean).join(" "); } function hasUnsafeInline(resources) { return resources.includes("'unsafe-inline'"); } /** A more specific directive is emitted only when the user scoped at least one source/hash to it. */ function isEnabled(sources) { return sources.resources.length > 0 || sources.hashes.length > 0; } /** * Builds one of the narrower directive strings: `script-src-elem`, `script-src-attr`, * `style-src-elem`, or `style-src-attr`. * * @param name The directive name, e.g. `"script-src-elem"`. * @param resources The sources the user scoped to this directive (e.g. `'self'` or a URL). * @param defaultResource The source to use when `resources` is empty. This is `'self'` for `-elem` without configured default resources and `'none'` for `-attr`. * @param sharedHashes Already-quoted hashes to also include here — Astro's tag hashes for `-elem`, or `undefined` for `-attr` (which never receives them). * @param ownHashes Unquoted hashes the user scoped to this directive. * @param suffix Optional text added at the end, e.g. ` 'strict-dynamic'`. */ function renderSpecificDirective(name, resources, defaultResource, sharedHashes, ownHashes, suffix = "") { // Per the CSP spec, browsers ignore `'unsafe-inline'` when a hash is present in the same // directive. Skip hashes entirely to preserve the user's `'unsafe-inline'` intent. const unsafeInline = hasUnsafeInline(resources); const hashes = new Set(unsafeInline ? undefined : sharedHashes); if (!unsafeInline) { for (const hash of ownHashes) { hashes.add(`'${hash}'`); } } let finalResources; if (resources.length > 0) { finalResources = resources.map((r) => `${r}`).join(" "); } else if (defaultResource === "'none'" && hashes.size > 0) { // `'none'` cannot coexist with other sources; if hashes were provided, emit only the hashes. finalResources = ""; } else { finalResources = defaultResource; } return `${name} ${[finalResources, ...hashes].filter(Boolean).join(" ")}${suffix};`; }