Right after publishing v0.1.3 of astro-strict-csp to handle Shiki inline styles and <head> theme scripts, I remembered another annoying CSP workaround sitting in my own website's codebase: inline event attributes.
When you enforce a strict Content Security Policy (script-src 'self' with zero 'unsafe-inline'), browsers don't just block <script> blocks—they also ruthlessly block every inline DOM event attribute like onclick, onmouseenter, or onsubmit.
If you want a simple <button onclick="window.print()"> in a template or markdown file, strict CSP forces you to give it an ID, open a separate <script> tag, query the selector, and attach an addEventListener. It’s tedious boilerplate that ruins the joy of writing simple HTML.
Since the whole point of building astro-strict-csp was to stop thinking about security workarounds across my projects, I moved this fix into the package and shipped v0.2.0. Now I can write lazy inline onclick attributes whenever I want and let the compiler turn them into strict-CSP-compliant, SRI-hashed bundles at build time.
1. The Build-Time Transformation: Zero 'unsafe-inline' Without Rewriting HTML
Instead of forcing you to refactor components or weaken your policy with 'unsafe-hashes', astro-strict-csp extracts inline event handlers during astro:build:done and rewrites them into data-csp-on-* attributes.
What you write in your Astro template or Markdown:
<button onclick="window.print()" class="btn">Print Recipe</button>
<a href="#" onclick="copyLink(this); return false;">Share</a>
What actually ships in production HTML:
<button data-csp-on-click="evt-a1b2c3" class="btn">Print Recipe</button>
<a href="#" data-csp-on-click="evt-d4e5f6">Share</a>
<!-- Automatically hashed by the SHA-384 SRI pipeline -->
<script type="module" src="/_astro/events-8f92a1.js" integrity="sha384-..." crossorigin="anonymous"></script>
Because the event extraction step runs right before the Subresource Integrity (SRI) stage in our single-pass in-memory pipeline, the generated /_astro/events-[hash].js bundle automatically gets a cryptographic sha384 integrity hash and is whitelisted in your Content Security Policy.
2. The Runtime Engine: Preserving Native Browser Semantics
Extracting the string inside onclick="..." into an external file is harder than it looks because browsers give inline event attributes very specific execution semantics that standard addEventListener callbacks don't have out of the box:
-
thisBinding: Insideonclick="copyLink(this)",thisrefers to the DOM element itself. -
The Implicit
eventArgument: Inline handlers have access to a localeventvariable without declaring it. -
return falseBehavior: Returningfalsefrom an inline handler automatically callsevent.preventDefault().
To mirror native browser behavior without attaching hundreds of individual listeners across the DOM, astro-strict-csp generates a single delegated dispatcher:
// Generated in /_astro/events-[hash].js
const HANDLERS = {
'evt-a1b2c3': function(event) { window.print(); },
'evt-d4e5f6': function(event) { copyLink(this); return false; }
};
['click'].forEach(evt => {
document.addEventListener(evt, e => {
for (const el of (e.composedPath ? e.composedPath() : [])) {
if (el.getAttribute) {
const id = el.getAttribute('data-csp-on-' + evt);
if (id && HANDLERS[id]) {
// Mirrors native inline event semantics:
// 1. `this` is bound to the element via .call(el, e)
// 2. `event` is passed as the parameter
// 3. `return false` prevents default browser action
if (HANDLERS[id].call(el, e) === false) {
e.preventDefault();
}
break;
}
}
}
}, { capture: true });
});
3. The { capture: true } Trick for Non-Bubbling Events
Most naive event delegation scripts attach a normal bubbling listener to document and check e.target. That works fine for click or keydown, but it fails completely on non-bubbling events like:
-
focusandblur -
mouseenterandmouseleave -
loadanderror scroll
Because those events do not bubble up the DOM tree, a normal listener on document never hears them.
However, per the W3C UI Events specification, even non-bubbling events still travel down the DOM tree during the Capture Phase before reaching their target. By registering the root listener with { capture: true } and walking e.composedPath(), our single delegated listener cleanly intercepts onfocus, onblur, and onmouseenter attributes across the entire document—including inside open Shadow DOM trees.
And just like the CSS extractor in v0.1.0:
-
Global Deduplication: If 50 buttons across 20 pages use
onclick="window.print()", the handler is hashed and emitted only once inHANDLERS. -
Region Masking: Code blocks (
<code>,<pre>),<textarea>,<script>strings, and HTML comments are masked prior to extraction, so tutorials discussingonclick="..."are never mutated. - Zero Dependencies: Still 0 external npm dependencies—pure native Node and Web APIs.
4. Ditching Custom CI Scripts for Google's release-please
Alongside the v0.2.0 feature work, I cleaned up the repository's release pipeline.
Previously, I used custom GitHub Actions workflows to handle version bumps and npm publishing. Custom release scripts always end up brittle—you forget to update CHANGELOG.md, mismatch a git tag, or fight token permissions.
I replaced the custom CI logic with Google's release-please. Now, because the repo already follows strict Conventional Commits (feat:, fix:, perf:), release-please automatically maintains a living Release PR. Every time a commit lands on main, it updates the semantic version and compiles a categorized changelog. Merging that PR automatically tags the release and publishes the new tarball to npm with zero manual steps.
Try It Out
npm install astro-strict-csp@latest
Links:
If you hit an edge case with a weird inline handler or want to request a feature, drop a bug report or feature request in the GitHub Issues.
Top comments (0)