I am not particularly paranoid about web security. For a static blog or a small product site, nobody is running nation-state zero-days against your markdown pages. But if a known vulnerability vector likeunsafe-inline exists—and closing it is a solved engineering problem—it is much better to lock it down once than deal with a preventable XSS headache down the road.

I originally built a strict Content Security Policy (CSP) pipeline for my Hono-based site. Later, when I started spinning up projects in Astro, I hit the exact same wall and found myself hardcoding the same build-time extraction scripts all over again.

Rather than copy-pasting that boilerplate into every subproject, I packaged the pipeline and published it on npm as astro-strict-csp.

"The goal wasn't to chase vanity A+ badges on Mozilla HTTP Observatory or Google CSP Evaluator—though it gets them automatically. The goal was to have a drop-in, zero-dependency Astro security integration that enforces a strict, hash-based Content Security Policy and Subresource Integrity without breaking Astro's built-in features."

Why Astro Fights Strict CSP Out of the Box

Astro generates fast static HTML, but trying to deploy an Astro site with a strict Content Security Policy (style-src 'self', script-src 'self') breaks your site immediately.

Out of the box, Astro relies heavily on inline code:

  • Shiki Syntax Highlighting: Injects thousands of rawstyle="color: #..." attributes directly onto<span> tags inside code blocks.
  • <Image /> Components: Outputs inline style attributes for aspect-ratio and layout dimensions.
  • Dark Mode & Theme Toggles: Requires synchronous inline <script> tags inside <head>to check localStorage before the first paint.

When developers try to lock down Astro security, they usually face an ugly choice: either spend days manually wiring up fragile script hashes, or surrender and add 'unsafe-inline' to their CSP headers—which effectively disables XSS protection.

astro-strict-csp hooks into astro:build:done and fixes this automatically at build time: it extracts every inline style and script into external hashed assets, rewrites the HTML references, computes SHA-384 Subresource Integrity (integrity="sha384-...") hashes, and generates both HTML <meta> policies and Cloudflare/Netlify _headers.

When building an HTML post-processor in Node, the default instinct is to npm install jsdom or cheerio. I refused to do that.

  • No Dependency Bloat: jsdom pulls in roughly 50 transitive dependencies and megabytes of dead weight. astro-strict-csp has zero dependencies in package.json, keeping the entire package footprint at 63 kB using only native node:crypto, node:fs, node:path, and node:url APIs.
  • Zero Serialization Side Effects: Full DOM parsers parse HTML into an abstract syntax tree and re-serialize the entire document back to a string. During re-serialization, parsers frequently mutate doctypes, strip custom element namespaces, or corrupt self-closing SVG attributes. By operating only on targeted spans of the raw string, untouched HTML bytes remain 100% identical to Astro's compiler output.

Everyone knows the classic StackOverflow warning against parsing HTML with regular expressions. If you write naive regexes against production HTML, you will corrupt the document. To make a zero-dependency extractor rock-solid, I had to engineer around three specific parser traps:

Trap A: The > Character Inside Attribute Values

A naive tag matcher like /<([\w\-:]+)([^>]*?)>/gibreaks the moment an HTML attribute legitimately contains a greater-than symbol:

<nav aria-label="Home > Blog > Security" style="margin: 0;">

A standard regex sees the > after Home as the closing bracket of the <nav> tag, truncating the element and leaving raw attribute text dangling in the DOM.

The fix is a quote-aware tag matcher (TAG_RE) that treats double-quoted ("[^"]*") and single-quoted ('[^']*') strings as atomic blocks before checking for the closing >:

const TAG_RE = /<([\w\-:]+)(\s+(?:[^>"'/]+|"[^"]*"|'[^']*'|\/(?!>))*?)(\s*\/?)>/gi;

Trap B: The Hyphen Word-Boundary Flaw

In JavaScript regular expressions, \b treats the hyphen (-) as a non-word character. If you search for \bstyle= or \bsrc=, your regex will falsely match custom data attributes:

<div data-style="card" data-src="/avatar.png">

To prevent the extractor from stripping data-style or v-bind:style, the matcher enforces strict negative lookbehinds for word characters, hyphens, and colons:

const STYLE_ATTR_RE = /(?<![\w\-:])style=(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/i;

Trap C: Mutating Code Blocks and Comments

If you write a technical blog post about HTML (like this one) that includes literal <style> or style="color: red" inside a <code> block or HTML comment, a global regex pass will extract and rewrite the example code inside your article.

Before running any extraction pass, maskHtmlRegions sweeps the document and replaces all HTML comments, CDATA sections, <textarea>, <pre>, <code>, and <script> bodies with inert placeholder tokens (<!--__CSP_MASK_0__-->). Once style extraction finishes on the safe structural HTML, the masked regions are restored in a single swap.

The Browser Quirk: HTML Entity Decoding in CSS

One of the subtlest bugs happened with inline font-familydeclarations generated by Markdown tools:

<span style="font-family: &quot;Fira Code&quot;, monospace;">

When a browser parses an inline style attribute in HTML, the HTML parser runs first, decoding &quot;into a literal " character before passing the string to the CSS engine.

However, if a build tool extracts that raw string and writes it directly into an external .css stylesheet:

/* INVALID CSS - Browser drops rule */
.is-a1b2c3 { font-family: &quot;Fira Code&quot;, monospace; }

The browser’s CSS parser does not understand HTML entities. It sees &quot; as a syntax error and silently drops the entire rule. astro-strict-csp runs a dedicated entity decoding pass (decodeHtmlEntities) to normalize &quot;, &#39;, &apos;, and &amp; into valid CSS syntax prior to hashing and emission.

Atomic MD5 Hashing & Cross-Page Deduplication

A 100-page documentation site using Shiki syntax highlighting can easily have 50,000+ inline style="color: #..." spans. If you generate a unique CSS class for every element instance, you end up with a multi-megabyte stylesheet.

Instead, astro-strict-csp normalizes declarations (color: #ff0000; font-weight: bold) and hashes the normalized string into a compact 6-character MD5 utility class (.is-7f3b1a).

A single Map<string, string> persists across the entire Astro build:

  • Every identical color token across all 100 pages maps to the exact same .is-7f3b1a class.
  • 50,000 inline style attributes collapse into ~150 unique utility rules inside a single 3 kB stylesheet (/_astro/inline-styles.css), linked once in <head>.

Preserving Synchronous Script Semantics (Preventing FOUC)

A common mistake in CSP extraction plugins is converting every inline <script> into <script type="module" src="...">.

In modern browsers, type="module" scripts are deferred by default—they wait until the HTML document is fully parsed before executing. If your <head> contains a synchronous theme script that reads localStorage.getItem('theme') to apply a .dark class before the first paint, turning it into an ES module causes a blinding white Flash of Unstyled Content (FOUC) on every page navigation.

astro-strict-csp inspects the original tag attributes:

  • Synchronous classic scripts stay synchronous (<script src="/_astro/inline-4b9f2a.js"></script> with no type="module" or defer).
  • Only scripts that originally declared type="module" are emitted as module scripts.

Single-Pass In-Memory I/O

Running multiple build plugins that each read HTML files from disk, parse them, and write them back to disk creates severe I/O bottlenecks on large static sites.

During astro:build:done, astro-strict-csp reads all generated HTML files into an in-memory pageMap once. All four pipeline phases mutate the strings in memory:

  1. Phase 1: Inline style extraction & cross-page class deduplication.
  2. Phase 2: Inline script externalization.
  3. Phase 3: SHA-384 Subresource Integrity (integrity) injection for all local scripts and stylesheets.
  4. Phase 4: Strict Content Security Policy generation.

Only pages where modified === true are written back to disk in a single final pass.

W3C <meta> CSP vs. HTTP Header Spec Divergence

Finally, there is an annoying specification split in W3C CSP Level 3. While HTTP response headers support the full CSP specification, the W3C explicitly forbids several directives inside HTML <meta http-equiv="Content-Security-Policy"> tags:

  • frame-ancestors
  • report-uri / report-to
  • sandbox

If you include frame-ancestors 'none' inside a <meta> tag, browsers ignore it and flood the DevTools console with spec violation warnings.

To handle both static hosting environments and edge CDNs cleanly, astro-strict-csp generates dual policies:

  • It automatically strips forbidden directives (frame-ancestors, report-uri, sandbox) when injecting the <meta> tag into HTML files.
  • It preserves the complete, unstripped policy (including frame-ancestors 'none') when writing dist/_headers for Cloudflare Pages and Netlify.

Installing It in Your Astro Project

If you want to lock down your Astro project without manually chasing Shiki styles or script hashes, install the package:

npm install astro-strict-csp

And add it to your astro.config.mjs:

import { defineConfig } from "astro/config";
import strictCsp from "astro-strict-csp";

export default defineConfig({
  integrations: [
    strictCsp()
  ]
});

The package is open source and live on npm (astro-strict-csp) and GitHub (Evgenii-Zinner/astro-strict-csp).

HTML and CSP edge cases in the wild are endless—if you run into a quirky Astro component, a weird third-party script, or have an idea for a new option, please open an issue, bug report, or feature request on GitHub.