A hand-authored, self-verifying CSP for Astro on Vercel — after security.csp broke offline support

Astro's own security.csp feature silently breaks service-worker registration. A hand-authored CSP for Astro on Vercel, verified by CI and a real browser.

The Astro rocket logo with a pink flame and the astro wordmark in white on a black card
On this page

Introduction

I submitted this blog’s URL to Trend Micro’s Site Safety Center, hoping for a clean bill of health. What came back, on September 2, 2026, was a reclassification result rating the site Dangerous, with a content type of Disease Vector.

Trend Micro Site Safety Center reclassification result for oharu121.com, dated September 2, 2026, 11:29 AM UTC, showing New Safety Rating: Dangerous and New Content Type: Disease Vector

The reclassification result. The site had previously been “Unrated” / “Untested.”

Neither label means what it sounds like for a static blog with no comment forms, no user uploads, and no third-party scripts. But I’d read that missing security headers can factor into how some scanners score a site, and this blog shipped none: no Content-Security-Policy, no HSTS, nothing. That was worth closing regardless of whether it was the actual cause here.

What I asked for was a careful implementation, researched against current best practice rather than copied from a template. What I got was a working Content-Security-Policy and the standard hardening headers, but not from the path either of us expected going in: Astro’s own official CSP feature turned out to have a real bug, one that would have shipped a broken service worker to every reader if a real browser hadn’t caught it first. This article is about that detour, and the two-tier verification it left behind. Whether it actually satisfies Trend Micro’s scanner is still an open question. I haven’t resubmitted the URL yet.

The obvious path: Astro’s own CSP feature

This site is a fully static Astro blog on Vercel: every page prerendered, no third-party scripts, no CDN fonts, nothing loaded from anywhere but its own origin. That should make it one of the easiest sites on the internet to lock down with a strict policy.

Astro has had stable, non-experimental CSP support since Astro 6.0, well before the version this site runs. Enable security.csp and Astro hashes every script and style it processes at build time, then either injects a <meta> tag or, paired with the Vercel adapter’s staticHeaders: true option, writes a real Content-Security-Policy header per page. The pitch is exactly what a static site wants: no hand-maintained hash list, correct per-page hashing even for content that differs on every page, like JSON-LD.

The agent building this tried that first. It didn’t even build on the first attempt:

Astro found issue(s) with your configuration:
! security.csp: Did not match union.
> Expected type boolean | Directives script-src and style-src (including
their -elem/-attr variants) are not allowed in security.csp.directives.
Please use security.csp.scriptDirective and security.csp.styleDirective
instead, scoping resources/hashes to the more specific directives with
the kind option ("element" or "attribute").

The fix was in the error message: a styleDirective.resources entry with kind: "attribute", not a bare string in the top-level directives array. The build went green, but a new warning appeared and stayed:

[WARN] [config] Shiki syntax highlighting uses inline styles that are not
compatible with Content Security Policy (CSP). Consider using Prism syntax
highlighting instead, or disable CSP if Shiki is required.

This blog’s code blocks render through Shiki with a light and dark theme, which turns out to mean every highlighted token carries its own style="--0:#24292e;--1:#e1e4e8" attribute for the two color values. That’s a real, unavoidable mechanism, not a misconfiguration: Shiki’s dual-theme output has to put the colors somewhere, and CSS custom properties on the element are how it avoids rendering the whole code block twice. 125 of the site’s built HTML files carried at least one. The fix was narrow and deliberate: scope 'unsafe-inline' to style-src-attr specifically, which doesn’t touch the hash enforcement on actual <style> elements.

What the browser actually saw

A CSP header only matters if a browser enforces it, and there was no easy way to check that locally. pnpm preview serves the built site with a plain static file server that attaches no headers at all, and with the adapter’s staticHeaders: true option on, Astro stops emitting the <meta> tag too, so there was nothing left to test against on a laptop. The agent wrote a small script: a local HTTP server that reads the real headers Astro generates and serves the build with them attached, then a headless-browser pass over a handful of representative pages, checking the console for anything naming Content-Security-Policy.

The first run found violations on every single page:

Executing inline script violates the following Content Security Policy
directive 'script-src ...'. Either the 'unsafe-inline' keyword, a hash
('sha256-CUF1UsP1KF+rujto8WMYHvUjA3RVvt9YEmoxguvdzDs='), or a nonce
('nonce-...') is required to enable inline execution. The action has been
blocked.

The blocked script was the service worker registration. Every page on this site registers a service worker for offline reading, and Astro’s hashing simply never covered it. Tracing the exact bytes back to source (matching each unmatched hash against a sha256 of the actual rendered <script> content) turned up a pattern: scripts written directly in the site’s top-level layout got hashed correctly, but the same kind of is:inline script, written in a child component the layout imports, did not. A search-shortcut script and an offline-page script had the identical problem. This wasn’t a caching artifact: the agent cleared every build cache (node_modules/.astro, dist, .vercel/output) and reran from nothing, and the same scripts came back unhashed.

Some <style> blocks had the same gap, and less predictably. On one article page, two component styles went unhashed while a third, sitting right next to them in the same page, was hashed correctly. All three were the same kind of scoped component style; nothing about the diff explained why one was covered and two weren’t.

One open question resolved cleanly in the other direction. JSON-LD blocks (<script type="application/ld+json">) carry different content on every page, title, date, description, which made them the reason Astro’s own hashing looked attractive in the first place. They never showed up in a single violation, on any page, regardless of content. script-src turns out not to gate a script element whose type isn’t a JavaScript MIME type at all, so this was a non-issue from the start; it just took a real browser to be sure.

How a Content-Security-Policy header is actually enforcedA four-step left-to-right flow. The browser requests a page; the server responds with the page plus a Content-Security-Policy header; the browser then checks each resource on the page against that policy as it loads. The outcome branches: an allowed resource loads and runs normally, a blocked one is stopped and a console violation is logged instead. A note below states that this check happens in the browser, separately from whatever a build tool did or did not hash correctly beforehand.1Browser requests the page2Server responds:HTML + a Content-Security-Policy header3Browser loads eachresource on the pageAllowedloads and runs normallyBlockeda console violation islogged insteadEnforced here, in the browser, at load time — separate from whatever a build tool hashed (or didn't hash) ahead of time.
A Content-Security-Policy header only constrains what the browser will run or render after the page arrives; it says nothing about what a build tool hashed correctly to get there.

Hand-authoring the policy instead

A CSP that silently breaks offline reading on every page is worse than no CSP, so the agent recommended abandoning Astro’s automatic hashing and hand-authoring the policy in vercel.json directly. I approved the pivot once the failure was demonstrated, not before; a broken service worker across the whole site was not a risk worth carrying for a “no hand-maintained hash list” convenience.

Hand-authoring solves the reliability problem but reopens the one Astro’s hashing was supposed to close: a handful of scripts on this site are genuinely fixed (a theme script, the search shortcut, the offline page, and the service worker registration itself), so they can be hashed once and pinned. But Astro’s default build also inlines small per-page stylesheets and small bundled <script type="module"> chunks directly into the HTML when they’re small enough, and which chunks that is varies by page, depending on which components that page happens to use. A single sitewide policy can’t chase a value that changes per page, so build.inlineStylesheets: 'never' and vite.build.assetsInlineLimit: 0 in astro.config.mjs force all of that external instead. style-src 'self' and script-src 'self' then cover it uniformly, with nothing left to hash by hand for that part.

The original plan also assumed the seven inline style="" attributes on the site could simply be refactored away into CSS classes, closing the CSP gap without any 'unsafe-inline' at all. That assumption didn’t survive a proper count. A grep for style={ turned up nine more: several article diagram components set the opacity of individual SVG shapes from a formula, one opacity value per data point in a loop. There’s no small set of CSS classes to convert a continuously computed value into, so style-src-attr 'unsafe-inline' stayed in the hand-authored policy too, this time as a deliberate, scoped decision rather than something discovered by accident.

I don’t run anything else on this domain and don’t plan to, which is the one fact that made the decision straightforward. The full tradeoff:

No preloadWith preload
When HTTPS is enforcedFrom a visitor’s first HTTPS visit onwardFrom the very first request any browser ever makes, even before a first visit
What includeSubDomains bindsThis domain, going forwardEvery subdomain, forever, until removal propagates
To undoChange or drop the headerSubmit for removal, then wait 6 to 12+ weeks for it to clear most browsers

Keeping the policy from going stale

A hand-authored hash list is only as good as the discipline that keeps it current, and CLAUDE.md’s own rule for this project is not to trust discipline where a script can check instead. Two different things can go wrong here, and they need two different checks.

Two checks for two different CSP failuresTwo cards side by side. The left card, CI, pnpm csp:check, catches a known script whose hash has gone stale: it recomputes the four fixed script hashes from the built HTML and runs on every push with no browser needed. The right card, Local, pnpm csp:sweep, catches a new violation anywhere on the site: it serves the real configured headers and loads every page in real headless Chromium, run by hand before shipping a change that touches a script, a style, or a component.CI — pnpm csp:checkCatchesA known script's hash gone staleHowRecomputes the 4 fixed scripthashes from the built HTMLRunsEvery push, no browser neededLocal — pnpm csp:sweepCatchesA new violation anywhere on the siteHowServes the real headers, loadsevery page in real ChromiumRunsBy hand, before shipping a change
A stale pinned hash and a brand-new violation fail differently, so one check catches each.

The first failure mode is narrow and mechanical: one of the four fixed scripts changes, and whoever edited it forgets to regenerate its hash. scripts/compute-csp-hashes.ts reads the actual built HTML (not the source, since hashing has to match Astro’s rendered output byte for byte) and either writes the current hash set into vercel.json (pnpm csp:build) or checks it’s already correct and exits non-zero if not (pnpm csp:check). That check now runs in CI, right after the build step, since it needs the built HTML to compare against.

The second failure mode is the one no fixed hash list can catch: a future MDX component with its own inline script or style, added by someone who has never read this article. Only a real browser enforcing the real policy against the real page can find that, which is what scripts/check-csp-sweep.ts does: it serves the built site with the exact headers read live out of vercel.json, so it can never test a policy that has already drifted from what ships, then loads every published article in every language it has, plus the utility pages, in headless Chromium. pnpm csp:sweep runs the whole site this way; pnpm csp:sweep <slug> checks one article. It isn’t wired into CI. This repo already has a precedent for exactly that call, a Playwright-based check for figures that overflow their boxes, and the reasoning carries over unchanged: no workflow here installs a Chromium binary, and that’s a cost worth avoiding for a check that only matters when someone touches a script, a style, or a component.

Even the sweep script needed a fix once it existed. Its first run reported violations on a Vercel Analytics endpoint that doesn’t exist locally:

Refused to execute script from '.../_vercel/insights/script.js' because its
MIME type ('') is not executable, and strict MIME type checking is enabled.

That reads like a CSP violation and isn’t one. X-Content-Type-Options: nosniff was blocking a 404 response with no Content-Type header from being executed as a script, a real browser protection, just the wrong one. The console-message filter had been matching on “refused to” generically instead of requiring “Content Security Policy” specifically, so it was catching both. Narrowing the filter, and giving the local server’s 404 responses a real Content-Type, made the false positive disappear without touching the actual check.

Verification: what’s live now, and what still isn’t

pnpm csp:sweep against the full site: 158 pages checked, zero violations. pnpm check and pnpm build both clean, and the service worker’s precached shell grew from 326KB to about 445KB (all the CSS and JS that used to be inlined is now external, and therefore precached), still under its own 500KB budget.

The first live check after deploying was a false alarm of its own. curl -sI https://oharu121.com/ right after the merge landed came back with no Content-Security-Policy header at all, and an Age: 333 on the response: a CDN cache hit from before the production deploy had actually finished. Waiting for the deploy job to complete and re-requesting with a cache-busted URL showed the real thing:

Content-Security-Policy: default-src 'self'; img-src 'self' data:; font-src
'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors
'none'; connect-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline';
script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' ...
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

Same result on an article page and on the one route this static site actually runs server-side, /api/likes/[slug].

What’s still open is the question the whole detour started with. I haven’t resubmitted the URL to Trend Micro yet, so whether any of this changes the “Dangerous” / “Disease Vector” result is unconfirmed. Missing security headers are a plausible contributor to how an automated scanner scores a site, but they’re a guess, not a diagnosis Trend Micro has published. If the classification doesn’t move, the headers were still worth shipping on their own terms; they just weren’t the whole answer to the question that started this.

Summary

The path here wasn’t the plan going in. Astro’s stable, documented security.csp looked like the correct long-term answer for a static site, and it took a real headless browser, not the framework’s own build output, to find that it silently drops service-worker registration for any script living outside the top-level layout. The policy that shipped instead is hand-authored, forces Astro’s build to stop inlining anything a sitewide policy can’t hash, and is checked two different ways for two different ways it could go wrong: a cheap CI script for a stale pinned hash, a local real-browser sweep for anything new. The original question, whether Trend Micro’s “Dangerous” classification changes, is still open.

References

Share this article