24 May 2026 · 3 min read
The CSP was in Terraform, the script was in Astro, and nothing checked
A postmortem on a bug that could not fail locally — the security header lived in one repository layer and the markup that violated it in another, and only production applied both.
This is a postmortem for a bug that never reached production, which is the only reason it is worth writing about: the interesting part is not the fix, it is why every check I had would have passed.
What would have happened
The CloudFront response headers policy for this site sends, among other things:
Content-Security-Policy: default-src 'none'; script-src 'self'; ...
No 'unsafe-inline' for scripts, no nonce, no hash. That is a deliberately strict
setting, and I picked it because a static portfolio has no excuse for anything looser.
The site also has a dark/light theme toggle. The obvious way to write it in Astro is a
<script> block inside the component:
<button id="theme-toggle">…</button>
<script>
document.getElementById('theme-toggle')?.addEventListener('click', () => { … });
</script>
Astro compiles that into a <script type="module"> inlined into the page. Under
script-src 'self', the browser refuses to execute it. The button would have rendered
perfectly and done nothing at all — no error page, no failed request, no alarm. Just a
control that silently does not work, on every browser, for everyone.
Why nothing caught it
Look at where each half of the problem lives:
| Half | Where it lives | Who applies it |
|---|---|---|
| The policy | infra/cloudfront.tf | CloudFront, in production |
| The violation | src/components/ThemeToggle.astro | Astro, at build time |
astro check type-checks components; it has no idea a CSP exists. terraform validate
checks HCL; it has never seen the markup. astro dev and astro preview serve over
plain HTTP from Node with no response headers policy in front of them, so locally the
toggle works flawlessly. Every gate was green, and each one was green correctly —
none of them was looking at both halves at once.
That is the actual class of bug here, and it is not specific to CSP. Any time a constraint is declared in the infrastructure layer and satisfied (or violated) in the application layer, the local environment is the one place both are never true at the same time.
The fix
Two changes, in that order.
Remove the inline script entirely. The site now ships exactly one JavaScript file,
/theme.js, which does two things: applies the stored theme before first paint, and
handles the toggle through a delegated listener on document:
document.addEventListener('click', (event) => {
if (!event.target.closest('#theme-toggle')) return;
// …
});
Delegation is what makes the single-file approach work — the handler does not need to
exist at the moment the button is parsed, so no component needs a script block of its
own. script-src 'self' is now true by construction rather than by discipline.
Then make the machine check it. Discipline decays; the next component I write in six
months will want its own <script>. So npm run build now ends with a script that
walks dist/, parses every tag, and fails the build on anything the deployed policy
would block:
$ npm run build
…
csp check: 11 page(s) clean — script-src 'self' holds.
It flags inline <script> blocks, on*= handlers, any subresource pointing off-origin,
and url() references to remote hosts in CSS. Introduce one deliberately and the build
stops:
csp check: 3 violation(s) across 11 page(s)
about.html:17 [inline-script] <script> has no src
about.html:17 [inline-handler] <img onclick=…>
about.html:17 [remote-subresource] <img src="https://cdn.example.com/x.png">
Roughly a hundred lines, no dependencies. It reads the artefact, not the intent, which is the whole point — it cannot be fooled by a component that means to be compliant.
What I took from it
The lesson I actually keep is not “watch out for inline scripts”. It is that a security control declared in one layer needs an assertion in the layer that has to satisfy it, and the assertion has to run against the built output, in an environment that does not need the real control to be present.
The same shape shows up everywhere once you look for it. A Kyverno policy that requires signed images is a production-only truth unless something verifies the signature in CI. A read-only root filesystem in a pod spec is a production-only truth unless the image build is tested against it. In each case the gap is not the control — the control is fine — it is that the fast feedback loop is the one place the control does not exist.
Cheapest possible fix, every time: a small script that reads what you are about to ship and checks it against what production is going to demand.