PL
← all posts

How I secured this site

A static site, a CSP with no inline code, DNSSEC, DMARC and a CI pipeline that stops me from breaking things myself. What I did, where I tripped up, and why there's almost no JavaScript here.

  • CSP
  • HTTP headers
  • DNS
  • supply chain
Czytaj po polsku

The portfolio of someone who wants to work in security is part of the CV itself. A security recruiter can open DevTools, check the headers or run the address through a scanner. I decided this site should demonstrate what I claim, not just talk about it.

First: as little attack surface as possible

The simplest defence is having nothing to attack. This site is fully static: no application server, database, login, forms or API. It’s just HTML, CSS and fonts, built once and served from a CDN.

There are also no cookies, trackers, analytics, third-party scripts or font CDNs. The fonts are served from the same domain, so a visitor’s browser never talks to anyone but me. No cookies means no cookie banner either.

I started the project in Next.js but quickly switched to Astro. Next.js static export injects inline scripts into every page, so the CSP would need 'unsafe-inline' or juggling hashes on every build. Astro ships zero JavaScript to the browser by default.

Content Security Policy

CSP tells the browser where a page is allowed to load resources from. Everything else is blocked, even if someone managed to inject code into the HTML. My policy:

Content-Security-Policy:
  default-src 'none';
  script-src 'self';
  style-src 'self';
  img-src 'self';
  font-src 'self';
  connect-src 'none';
  object-src 'none';
  base-uri 'none';
  form-action 'none';
  frame-ancestors 'none';
  upgrade-insecure-requests

It starts from default-src 'none' (“nothing is allowed”) and only permits what the site actually needs, and only from its own origin. There is no 'unsafe-inline' and no 'unsafe-eval', so even an injected <script> tag or onclick attribute won’t run. form-action 'none' and base-uri 'none' close two less obvious doors: redirecting form submissions and hijacking the base URL for links. frame-ancestors 'none' blocks framing, which prevents clickjacking.

At first I had script-src 'none', because the site had no JavaScript at all. Then I added a mobile menu built on the native Popover API, which works without scripts but doesn’t close after you tap a link. For that I added a single menu.js file (7 lines) and changed the policy to 'self'. Without JS the menu still works, you just close it by hand.

Where I tripped up

A strict CSP quickly exposes the things your tools do behind your back:

  • A font as a data: URI. Vite inlines files under 4 KB straight into the CSS as base64 by default. One small font subset got inlined and font-src 'self' blocked it. I could have added data: to the policy, but I set assetsInlineLimit: 0 instead so every file stays separate.
  • Syntax highlighting. Astro’s default highlighter (Shiki) colours code with style="..." attributes, which style-src 'self' blocks. I switched to Prism, which uses CSS classes.
  • “CSP blocks eval” in the console. That wasn’t my code but browser extensions injecting scripts into every page. The CSP did exactly what it should.

The other headers

Header Value Why
Strict-Transport-Security max-age=63072000; includeSubDomains; preload HTTPS only for 2 years. The .dev TLD is on the HSTS preload list anyway.
X-Content-Type-Options nosniff The browser doesn’t guess file types.
X-Frame-Options DENY Blocks framing in older browsers (newer ones use frame-ancestors).
Referrer-Policy strict-origin-when-cross-origin Other sites don’t get the full URL the visitor came from.
Permissions-Policy camera=(), microphone=(), geolocation=(), … The site opts out of camera, microphone, location and payment access up front.
Cross-Origin-Opener-Policy same-origin Isolates the window from pages opened on other origins.
Cross-Origin-Embedder-Policy require-corp Only loads resources that explicitly allow it.
Cross-Origin-Resource-Policy same-origin My files can’t be embedded on other sites.

DNS and email

Security doesn’t stop at HTTP. Someone who hijacks DNS or spoofs the domain in email bypasses every header.

  • DNSSEC is enabled: DNS answers for the domain are signed and the DS record lives in the .dev registry. A validating resolver won’t accept a forged record.
  • SPF, DKIM and DMARC with a p=quarantine policy: mail servers know which servers may send mail for my domain, and forgeries end up in spam.
  • Registrar transfer lock, so nobody can quietly move the domain elsewhere.
  • /.well-known/security.txt per RFC 9116: if someone finds a problem, they know where to report it.

Supply chain

Most of the code that ends up in the build isn’t mine, so I keep an eye on dependencies:

  • package versions are pinned (--save-exact) and locked in package-lock.json, and I pick versions that have been out for at least a few days, not ones from an hour ago;
  • CI runs on every push: a clean install (npm ci --ignore-scripts), npm audit (fails on high/critical vulnerabilities) and a build;
  • the same workflow inspects the built HTML and CSS: an inline <script>, <style>, a style= attribute or a data: URI in the CSS fails the build. That’s the font bug from above, caught automatically;
  • GitHub Actions are pinned to specific commit SHAs rather than tags, so nobody can swap the action’s code under the same version number. The workflow only has read access to the repo;
  • Dependabot proposes package and action updates every week.

Privacy

The site only shows what I want to show: my name, city and an email address on my own domain. No phone number, date of birth or home address. Before publishing the photo and CV I strip their metadata: EXIF with GPS and phone model, and author and file paths in the PDF.

Results

What I learned

The biggest lesson wasn’t setting the headers, it was understanding what breaks them. Every CSP exception you’re tempted to add is a sign that some tool is doing something you can’t see. It’s better to reconfigure the tool than loosen the policy. And a check that stops a problem from coming back is worth more than a one-off fix.

← all posts