A strict-CSP personal site, and the bugs its checks caught
A security engineer’s personal site should practice what it preaches. This one is static, served from Cloudflare, and runs under a strict Content Security Policy. This post covers how that policy is enforced rather than just configured, and the real bugs that the automated checks caught while I was building it.
The policy
The site is built with Astro, which can emit a CSP and hash every inline script it generates. The configuration is short:
security: {
csp: {
algorithm: "SHA-256",
directives: [
"default-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
],
},
},
There is no unsafe-inline and no unsafe-eval. Anything the page runs is either a file served from the same origin or an inline script whose hash is in the policy. A <meta> CSP cannot set frame-ancestors, so the response headers cover that and the rest of the hardening: HSTS, X-Content-Type-Options, Permissions-Policy, COOP and CORP.
A policy like this is easy to weaken by accident. One convenient inline style="..." or onclick handler and the policy either breaks or gets loosened to make it work. So the rules are enforced by tests.
Tests that fail the build
A second test suite runs against the built dist/ folder and checks every page:
- the CSP meta tag exists and contains no
unsafe-inlineorunsafe-eval - no element has a
styleattribute or anon*event handler - every executable inline script has a matching hash in the policy
- no script, image, font, frame or stylesheet loads from another origin
- every internal link points at a page or asset that exists
The “no inline styles” rule is two lines:
expect(html).not.toMatch(/\sstyle="/);
expect(html).not.toMatch(/\son[a-z]+="/i);
The suite also checks security.txt: it must exist and its expiry date must be in the future but less than a year away. A security contact file that has silently expired is worse than none, so the build fails when that is about to happen.
What the constraint changed
The strict policy changed how I wrote the interactive parts of the site. I wanted an interactive page with draggable windows and a couple of small games. Draggable windows normally mean writing style="left: 120px" from JavaScript, which the policy and the tests forbid.
The way out is that setting a property through the CSS object model is allowed, even though a style attribute in markup is not. The window manager writes custom properties, and the stylesheet reads them:
win.style.setProperty("--x", `${state.x}px`);
The same discipline applies to content. The terminal prints output with textContent, never innerHTML, and a test scans the source for innerHTML, eval and network calls, so a future change cannot quietly add one. An end-to-end test also types <img src=x onerror=...> into the terminal and checks that it appears as text and never runs.
What the quality checks found
I added three more gates: an axe-core audit of every page, Lighthouse with a perfect accessibility score required, and end-to-end tests in real Chrome, including phone-sized and touch emulation. They found real problems that I would have missed by eye:
- A focus bug. Dragging a window by its title bar cancels the browser’s default focus change. After clicking another window, the first one still held keyboard focus, so a running game kept receiving keystrokes and never paused. The fix was to move focus explicitly when a window is raised. Only a real browser test could see this.
- A dock that overflowed phones. Adding a seventh dock icon made the dock about 410 pixels wide, which stretched the page grid and pushed every window off a 375-pixel screen. A layout test failed immediately.
- Controls out of reach. On a small phone in portrait, the on-screen game controls sat 178 pixels below the visible window, and in landscape the game board was taller than its window. Both were invisible on a desktop monitor.
- The iOS zoom trap. iOS Safari zooms the page when you focus a text field with a font under 16 pixels. The terminal input was 13.6 pixels. That one came from auditing the code against known mobile pitfalls, and I have not yet confirmed the fix on a real phone.
Making sure the tests can fail
A passing test only means something if it can fail. After fixing the focus bug, I temporarily removed the fix and reran the suite. Exactly the two tests that should catch the bug failed. Then I restored the fix and everything passed. It takes a minute and it tells you the test is not decoration.
The opposite problem showed up too: a flaky test. One check read the best score the instant the game-over message appeared, but the on-screen numbers redraw one animation frame later. On a slow machine it read the old value. The fix was to treat the stored value, written at game over, as the source of truth and wait for the display to match it. Tests that assert on a UI should wait for an observable state, not for a fixed delay.
Supply chain, briefly
CI actions are pinned by commit SHA, with Dependabot proposing updates. Two small lessons came out of that. CodeQL’s init and analyze steps must run the same version, so Dependabot’s two separate pull requests each failed on their own and the right change was one combined pull request. And I run Lighthouse through a pinned npx call instead of installing it, because installing it added eleven high-severity advisories to the project’s audited dependency tree.
What is still open
The end-to-end tests run only in Chrome, with emulated phones. They are not a substitute for real devices and Safari. There is also no CSP violation reporting yet, so a violation would only show up in a visitor’s browser console, not in front of me.
If you build something similar, the part worth copying is not the specific policy. It is the habit of encoding each security rule as a test that fails the build, then breaking the rule on purpose once to confirm the test notices.