Security

Hardening

Everything on this page is checkable in about ten seconds with a scanner or your browser's dev tools — that's the point. I'd rather you verify a shorter list than take a longer one on faith.

The headline constraint

The public site ships zero inline <script> and zero inline <style>. That's not a stylistic choice — it's what lets the Content-Security-Policy below be plainscript-src 'self' with no unsafe-inline, no nonces, no hashes. An A+ scan result with unsafe-inlinein the policy is worse than a lower score with a policy that's actually strict, because unsafe-inline is the exact thing CSP exists to prevent — a single injected<script> tag anywhere on the page runs with full privileges. This site needs no exception to get to strict, by construction: astro.config.mjs setsinlineStylesheets: 'never', every script lives in/public/scripts/, and the build is grepped for stray inline content before every deploy.

Headers, one at a time

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Tells the browser to never even attempt plain HTTP again, for two years, including subdomains. Cost: if this domain ever needs a subdomain that can't do HTTPS, that's now a real constraint, not a five-minute fix. Worth it for a domain with no legacy HTTP dependents.
Content-Security-Policy
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'self' — everything loads from this origin, nothing else. frame-ancestors 'none' means this site can't be framed by anyone (clickjacking). Cost: no third-party embeds, ever, without a deliberate policy change — which is the intended cost.
X-Content-Type-Options: nosniff
Stops the browser from guessing a file's type from its content instead of trusting the declared one. Cheap, no real cost.
Referrer-Policy: strict-origin-when-cross-origin
Sends the full URL as a referrer to same-origin requests, only the origin to cross-origin ones. Cost: any external analytics on the receiving end sees less. There is no external analytics on the receiving end.
Permissions-Policy
Camera, microphone, geolocation, payment, and USB are all denied outright. This site has no legitimate use for any of them, so there's no cost to closing the door.
Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin
Isolates this site's browsing context and resources from other origins trying to read them cross-origin (the class of attack Spectre made a browser-level concern, not just a server one).
/.well-known/security.txt (RFC 9116)
Contact info for anyone who finds something. Costs nothing, and it's the difference between a researcher emailing you and a researcher tweeting about it.

Subresource Integrity: not applicable — there are no third-party-hosted scripts or stylesheets on this site to add SRI hashes to. Fonts are self-hosted under /fonts/ rather than pulled from a font CDN, for the same reason: one less origin this site has to trust.

DNSSEC, CAA records, and SPF/DKIM/DMARC are registrar/DNS-level controls, not application code — they're on the punch list for whoever has access to the domain registrar, not something this build can configure from inside the repo. An unprotected domain is spoofable for email even if it never sends any, which is the actual reason to set SPF/DKIM/DMARC on a domain with no hosted mail.

What I chose not to do, and why

Certificate pinning. Pinning breaks the site the moment a certificate rotates unexpectedly, and Cloudflare already handles certificate lifecycle for this domain. The failure mode of a misconfigured pin — the site goes dark for everyone until the pin is fixed — is worse than the attack it defends against for a site with no login flow beyond a single admin secret.

A WAF. A web application firewall is built to stop attacks against a dynamic backend with a real attack surface — SQL injection, auth bypass, file upload abuse. This site's public surface is static HTML plus a handful of narrow, parameterized D1 queries behind either no auth (public, read-only, rate-limited) or admin auth. A WAF here would mostly be scanning for vulnerability classes that don't exist in this architecture, for a cost (latency, complexity, another thing to misconfigure) that isn't worth paying yet. Revisit if the attack surface grows.

COEP (Cross-Origin-Embedder-Policy). It exists to enable powerful isolated features like SharedArrayBufferin cross-origin-isolated contexts. This site doesn't use anything that needs it, and turning it on for no reason just adds a way for a future me to break font loading by accident.

What broke

The honest version of a hardening page includes the failures, not just the wins. These are all things that looked right until they were actually tested — full detail in the repo'sDECISIONS.md, condensed here:

  • The traffic strip and the fingerprint demo both hit the same CSP wall the first time I wrote them: the obvious way to draw a dynamic bar chart or detect installed fonts iselement.style.whatever = value — and Chrome enforcesstyle-src against that exact JS pattern the same way it enforces against an inline style="" attribute. Without unsafe-inline, both silently did nothing. Fixed by drawing the chart as SVG with real attributes instead of CSS, and switching font detection todocument.fonts.check(), which needs no element styling at all.
  • The traversal classifier doesn't catch what I expected it to. A request for/%2e%2e/%2e%2e/etc/passwd gets fully normalized to/etc/passwd by the Fetch API's URL parser before a Cloudflare Pages Function's code ever runs — the same is true for plain ../. Only double-encoded and backslash-encoded variants survive to actually be classified as traversal. Not a real vulnerability here (static site, nothing to traverse into), but a real gap in that one telemetry bucket, and a good example of testing surfacing something code review wouldn't have.
  • A visually-hidden login form wasn't actually hidden.element.hidden = true was set correctly, but a class selector's display: flex outranks the browser's default [hidden] { display: none } in specificity. The admin login form and the dashboard both rendered at once until I added an explicit [hidden] { display: none !important; }reset.

What this can and can't actually tell me about a visitor

This is the part most portfolios like this one skip, and it's the actual payoff of building an intelligence system instead of just writing about one.

What it can do: map a visitor's request to a company-level or ISP-level network, using the ASN and organization name Cloudflare already resolves for every request. That's it. It's the same category of information any web server operator has always had. Commercial person-level identification tools exist and go further — but even the ones vendors sell have real ceilings, commonly cited in the 30-40% match-rate range, and they get there by correlating against identity graphs (purchased data, device graphs, cross-site tracking cooperatives) that a solo personal site has no access to and this one doesn't build toward.

What the browser makes impossible, regardless of intent:reading another site's cookies — the same-origin policy prevents that categorically, it's not a permission this site could request even if it wanted to. Reading browsing history is similarly closed: Chrome 136 partitioned the CSS :visited selector specifically to kill the last practical way to infer it. This site doesn't attempt either, and couldn't if it tried.

What this site actually does, then: passive network metadata plus ASN on an authenticated private dashboard only I can see; short AI-generated summaries over that same metadata, labeled as inferences and never generated for traffic that looks purely automated; and an opt-in fingerprint demo that fingerprints nobody who doesn't press the button.

What it deliberately refuses: covert client-side fingerprinting of anyone who hasn't consented, and any attempt to build toward identifying a specific person without a lawful basis for doing so.

Where I think the line actually sits: a footer privacy policy is not the same thing as consent under GDPR, and I'm not going to write this site's privacy pageas if it were — it's a disclosure, not a mechanism for obtaining permission. The lawful basis this site actually leans on for the private dataset is legitimate interest: understanding traffic to a site I own, using data that's already minimal and network-level, with a stated retention window that's actually enforced by code rather than just claimed. If a future version of this site ever wanted to do more than that — identify a specific person, not just a network — that's a different lawful basis I don't currently have, which is exactly why this version doesn't attempt it.

Last reviewed: 2026-09-10.