~/sang

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 “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:

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.

← all posts

guest@sang:~$