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'andframe-src 'none'— no plugins, no embedded frames. Nothing on the site wants either.worker-src 'none'andmedia-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 anX-Frame-Options: DENYheader, 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.