KRULZ GAMES / NOTES / A STRICT CSP

Build note · 9 September 2026

A STRICT CSP|THAT COSTS NOTHING

Locking a site down is expensive when it depends on other people’s servers. This one depends on none, which turns a project into a header.

Every response from krulz.games carries a content security policy that starts default-src 'self' and gets stricter from there. Written out, most of it is a list of things switched off: object-src 'none', frame-src 'none', worker-src 'none', media-src 'none', form-action 'none', frame-ancestors 'none', base-uri 'self', and upgrade-insecure-requests on the end.

On most sites that list would be a multi-week migration with a staging environment and a report-only phase. Here it was an afternoon, and the reason is not skill. It is that the policy was written after the dependencies were already at zero, rather than before.

WHY IT IS USUALLY HARD

A normal page pulls a font from one host, analytics from a second, a tag manager from a third, and two or three libraries from a CDN. Each one needs a line in the policy, each line is a hole, and the tag manager is the worst of them because its entire job is loading code you have not seen yet. You cannot write a tight policy around a system designed to fetch arbitrary scripts at runtime.

So the fight is never really with the syntax. It is with the architecture. default-src 'self' is a statement about what your site depends on, and if the answer is “eleven other companies” then the policy is going to say so.

This site loads no font from the network, no analytics, no library, no framework, and no image from anywhere else — it has no image files at all. There was nothing to allow.

THE ONE EXCEPTION

connect-src allows exactly one extra origin: the Firebase Realtime Database host that stores the public NodBlocks high-score list. Without it the leaderboard button on the NodBlocks page would go quiet.

One exception is a good number, because it is small enough to justify in a sentence. It is one host, reached by one button, for one public list that contains five-character initials and scores — no personal identifiers and no device IDs. The fetch happens when a visitor asks for it, not on load.

The useful discipline is not “zero exceptions”. It is that every exception should be nameable, and that you should be able to say what it carries.

THE COMPROMISE THAT STAYED

script-src keeps 'unsafe-inline', and it would be dishonest to file this note without saying so.

The whole site is built on inline scripts. The textbook fix is a nonce — a random value minted per response and stamped on every allowed script tag — and a static deploy has nowhere to mint one. There is no server rendering the HTML per request; there are files.

What that costs is the part of CSP that stops an injected inline script from running. What limits the damage here is that there is very little to inject through: the site takes no user input, renders nothing out of query parameters, has no comment field and no search box, and form-action 'none' means a form could not post anywhere even if one appeared. The realistic injection route is the deploy itself, and no CSP directive defends against a compromised deploy.

The policy still earns its keep with everything else: third-party scripts, frames, objects, workers and form posts remain blocked, and the page cannot be embedded in someone else’s frame. A weakened directive is not a useless policy. It is one honest line in a list of strict ones.

THE FREE DIRECTIVES

The 'none' entries are the cheapest security work available anywhere. Each one costs a few characters and removes a category of attack that the site was never going to use:

  • object-src 'none' and frame-src 'none' — no plugins, no embedded frames. Nothing on the site wants either.
  • worker-src 'none' and media-src 'none' — no workers, and no audio or video files, since every sound is synthesised in the browser.
  • form-action 'none' — there is no form on the site, and now there is no destination for one.
  • frame-ancestors 'none', alongside an X-Frame-Options: DENY header, so the site cannot be loaded inside someone else’s page. Two headers for one job, because the older one is still what some clients read.
  • base-uri 'self' — an injected <base> tag can silently repoint every relative URL on the page. Cheap to close.

Alongside them: X-Content-Type-Options: nosniff, a referrer policy of strict-origin-when-cross-origin, and a permissions policy that switches off camera, microphone, geolocation and interest-cohort. The camera line is worth a smile on a site about a camera-controlled game: the site has no camera at all. The head that follows your cursor on these pages is drawn, not filmed.

THE SAME INSTINCT, ELSEWHERE

The email address does not appear in the source of any page. It is stored reversed, with a hash in place of the at-sign, and assembled in the browser; the visible text stays readable in words either way. Most address harvesters never render a page — they pull the source and run one pattern over it — so the common case comes away with nothing.

That is a mitigation and not a defence, and the difference matters. A harvester that executes scripts gets the address anyway. It is worth doing because it is nearly free and it removes the majority case, which is the same argument as every 'none' above.

WHAT TO TAKE FROM THIS

The order of operations is the whole lesson. A strict policy is not a thing you retrofit onto a page with eleven third parties; it is a thing you get almost for free if you keep the count near zero and write the header at the end.

And a caveat, because a note that only flatters its own approach is not worth reading: none of this addresses the actual risks to a small static site, which are the deploy pipeline, the hosting account and the domain registrar. A perfect CSP on a domain someone else can transfer is decoration. The headers are the easy part, and they are worth doing precisely because they are easy — not because they are the important part.