~/blog/content-security-policy

Implementing Content Security Policy on a Static Site

Published on April 26, 2025 · 6 min read

Content Security Policy (CSP) tells a browser which resources a document may load and which kinds of inline code may run. A useful policy can block an injected script even when unsafe markup reaches the page.

OWASP recommends CSP as an additional XSS defense. Contextual output encoding, safe DOM APIs, and HTML sanitization still have to handle untrusted data correctly. CSP gives the browser an independent rule to enforce when those controls fail.

I added CSP to this static site after looking at the HTML produced by its build. The implementation uses SHA-256 hashes for inline code, filters article HTML before rendering, and inserts the policy into every generated page.

Static Output Still Has Trust Boundaries

A static site can still combine Markdown, raw HTML, templates, analytics code, and third-party resources in one document. The absence of a runtime application server does not make all of that input equally trusted.

On this site, raw HTML from Markdown passes through a build-time filter that removes executable elements, inline event handlers, and dangerous URL schemes. The resulting page then passes through a CSP generator. The filter handles known execution paths in article content; CSP checks what the browser may execute in the finished document.

That order is deliberate. If the build authorized code first and tried to classify it later, injected code could receive the same authority as a template script.

Generating Hashes from the Final HTML

A server can create a new nonce for every response. Static hosting repeatedly serves the same file, which makes hashes a good fit for stable inline code.

The CSP generator scans each completed page for inline scripts without a src attribute. It excludes scripts inside the article’s <main> region, hashes each remaining script body, and adds the results to script-src. This is the core of the implementation:

javascript
const sha = value =>
  `'sha256-${createHash('sha256').update(value, 'utf8').digest('base64')}'`;

const scriptHashes = [
  ...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi),
]
  .filter(match => !/\bsrc=/i.test(match[1]))
  .filter(match => !insideMain(match.index))
  .map(match => sha(match[2]));

const policy = [
  "default-src 'self'",
  "object-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  `script-src 'self' ${scriptHashes.join(' ')}`,
].join(';');

A CSP hash covers the exact UTF-8 bytes inside the element. A formatting change alters the hash, so the generator runs after the final HTML has been assembled. Hand-maintained hashes would drift whenever an inline block changed.

Inline <style> blocks work with ordinary style-src hashes. Style attributes are different: CSP Level 3 requires the 'unsafe-hashes' source expression before their values can match a hash. The site includes that expression and hashes each remaining style attribute.

'unsafe-hashes' does not allow every inline style. It lets an exact hashed value match when used as an attribute. The hash proves the bytes, not the element or location in which those bytes appear, so removing inline style attributes would still be preferable.

Keep Content Outside the Script Trust Set

Hashing every inline script in the output would defeat the control. An injected script would be discovered by the generator and added to the policy that was supposed to block it.

The current implementation uses two checks. The article filter removes script elements before page assembly, and the hash pass ignores scripts whose position falls inside <main>. Together they prevent an ordinary content script from entering script-src.

The position check is a backstop rather than a complete model of ownership. It infers trust from serialized HTML boundaries, which can become fragile as templates change. A stronger implementation would collect hashes when template-owned scripts are emitted, or mark those scripts explicitly, instead of deciding ownership after rendering. Any future source of externally editable content should also use an HTML parser and a documented allowlist rather than relying on the site’s current surgical filter.

The Generated Policy

The generated policy has this shape:

http
default-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
script-src 'self' <required hosts> <template script hashes>;
style-src 'self' 'unsafe-hashes' <required hosts> <style hashes>;
img-src ...;
font-src ...;
connect-src ...;
manifest-src 'self';
worker-src 'self';
upgrade-insecure-requests

The host entries cover the site’s current analytics and font providers. They receive more trust than a single inline hash because an allowed script origin may expose other executable resources. I keep those entries tied to actual dependencies and remove them when a dependency disappears.

This is a hash-based policy with host allowlists, rather than the minimal hash-based strict policy described by OWASP’s CSP guidance. Adding strict-dynamic would cause modern browsers to ignore script host sources and trust scripts loaded by an authorized root script. That can simplify a dependency chain, but the root script then controls which further scripts run. It needs a review of every dynamic script-loading path.

Limits of a Meta-Delivered Policy

This site currently injects CSP through:

html
<meta http-equiv="Content-Security-Policy" content="..." />

The CSP specification prefers an HTTP response header and gives meta delivery several limits:

CapabilityMeta elementResponse header
Enforced fetch and execution rulesYes, for content parsed after the elementYes
Content-Security-Policy-Report-OnlyUnavailableAvailable
frame-ancestors and sandboxIgnoredAvailable
report-uriIgnoredAvailable, though deprecated
Reporting API configurationNeeds response headers such as Reporting-EndpointsSupported by response headers

The generator inserts the meta element immediately after the character encoding declaration. Resources fetched before that point would escape the policy, including preload links sent in HTTP headers. Changing the meta element’s content attribute after parsing also has no effect.

My next deployment step is to configure the hosting layer to send the same policy as a response header. That also allows framing protection:

http
Content-Security-Policy:
  frame-ancestors 'none';
  form-action 'self';
  object-src 'none';
  base-uri 'none';
  ...

This site does not need a <base> element, so base-uri 'none' is the tighter header policy. A site that deliberately uses relative URLs through <base> may need 'self' or another specific source.

Rollout and Regression Tests

For a header migration, I would first inventory every required origin and inline block. I would then deploy the intended header through Content-Security-Policy-Report-Only, classify violations at a controlled endpoint, and fix unexpected dependencies. Enforcement follows only after the expected pages work under browser tests. The report-only policy can stay in place beside the enforced policy while testing a future revision.

The build needs assertions for the trust boundary as well as policy syntax. It should detect a template script missing from the hash set, a content script that becomes authorized, an unexpected host source, a missing directive, or a policy placed too late in the document. A browser regression test should run a known template script and attempt an injected content script on the same page. Only the template script should execute.

Console violations are useful during development but do not provide production monitoring. Violation reports may include page URLs and samples from blocked resources. A reporting endpoint therefore needs its own access rules and retention policy, with collection limited to data needed for diagnosis.

Where CSP Stops

CSP cannot sanitize HTML or repair an unsafe rendering path. It cannot make an allowed third-party script trustworthy. Authorization, CSRF protection, transport security, and browser support remain separate concerns.

The policy is useful because it gives the browser a narrow account of what this page expects to run. Its value depends on keeping that account aligned with the build. Every new inline block or external origin should cause a deliberate policy change, a failed test, or both.

Further Reading