DEV Community

Cover image for Compiling Inline Event Handlers (onclick) for Strict CSP in Astro (astro-strict-csp v0.2.0)
Evgenii Zinner
Evgenii Zinner

Posted on

Compiling Inline Event Handlers (onclick) for Strict CSP in Astro (astro-strict-csp v0.2.0)

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. this Binding: Inside onclick="copyLink(this)", this refers to the DOM element itself.
  2. The Implicit event Argument: Inline handlers have access to a local event variable without declaring it.
  3. return false Behavior: Returning false from an inline handler automatically calls event.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 });
});
Enter fullscreen mode Exit fullscreen mode

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:

  • focus and blur
  • mouseenter and mouseleave
  • load and error
  • 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 in HANDLERS.
  • Region Masking: Code blocks (<code>, <pre>), <textarea>, <script> strings, and HTML comments are masked prior to extraction, so tutorials discussing onclick="..." 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
Enter fullscreen mode Exit fullscreen mode

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)