All posts

Checking a static site for leaks before going live

Before this site got its own domain, I went through the build output the way I’d look at anything else that’s about to face the internet. Your source code is one thing. What actually gets uploaded is another, and that’s the part people can read.

Here’s the checklist I ended up with.

1. Source maps and absolute paths

Source maps can contain your original source and the full paths from your machine, like /Users/yourname/projects/.... That’s a username handed out for free.

find dist -name '*.map'
grep -rl "sourceMappingURL" dist
grep -rlE "/home/|/Users/" dist \
  --include=*.html --include=*.js --include=*.css --include=*.xml

All empty for me, because Astro doesn’t generate source maps for production builds. I still turned them off explicitly, so a config change or an update can’t quietly bring them back:

// astro.config.mjs
export default defineConfig({
	vite: {
		build: {
			sourcemap: false,
		},
	},
});

Small tip: limit that last grep to text files. Without --include, it matched random bytes inside a font and a JPEG that happened to spell C:\. Not a leak, just noise.

2. Your own name

Simple, but worth doing:

grep -ri yourname dist/

Try your username, your real name, any other domains you own, and your email. Nothing came up here, but this is the check I’d never skip.

3. The generator tag

The Astro blog starter puts this on every page:

<meta name="generator" content="Astro vX.Y.Z">

Not personal, but it tells anyone exactly which version to look up vulnerabilities for. For a static site that’s low risk, but there’s also zero reason to announce it. Gone.

4. HTML comments

Any <!-- comment --> in an .astro template ships to the browser. Mine were harmless notes like “Global Metadata”, but they’re still internal notes on every page.

The fix is to use expression comments, which Astro drops at build time:

{/* This one stays in the source and never reaches the browser */}

5. Photo metadata

Phone photos can carry GPS coordinates, the capture date and the device model. Check before publishing:

exiv2 -pa photo.jpg

Mine had no GPS, just an orientation tag, an empty IPTC block and a colour profile. I stripped it all anyway. One thing to watch: if you drop the colour profile without converting first, the colours can shift. I re-saved it as plain sRGB with no metadata:

import sharp from 'sharp';

await sharp('photo.jpg')
	.rotate()               // apply orientation to the pixels
	.toColorspace('srgb')   // convert before the profile is dropped
	.jpeg({ quality: 92 })  // sharp writes no metadata by default
	.toFile('photo-clean.jpg');

Keep in mind that stripping metadata doesn’t change what’s in the picture. A recognisable view or a number plate is still a recognisable view or a number plate.

6. Files you don’t see on the page

The About page shows a resized version of my photo. But the full-size original was also in the build, because the page used it as its link preview image. Nobody would see it while browsing, but anyone could grab it.

ls dist/_astro

Look at what’s in there and ask why each file exists. I switched all link previews to a dedicated preview image, and the original dropped out of the build.

7. What’s actually live

I connected the domain to my Cloudflare Pages project, planning to deploy the new version later. But connecting a domain doesn’t wait for you: it immediately serves whatever was deployed last. In my case that was an older build I wasn’t done with.

So check the live site, not just your local build:

curl -s https://example.com/ | grep -o "<title>[^<]*</title>"

If it’s not what you expect, deploy the right version or lock it down (Cloudflare Access works) before you do anything else.

8. Security headers

A static site has no logins or forms, but headers are cheap protection, and they’re the first thing someone checks with curl -I. On Cloudflare Pages you set them with a _headers file in your public folder. Mine looks roughly like this:

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'
  Strict-Transport-Security: max-age=31536000
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), geolocation=(), microphone=()
  ! Access-Control-Allow-Origin

That last line removes a header instead of adding one. Cloudflare Pages sends Access-Control-Allow-Origin: * by default, which lets scripts on any other site read your pages. For a public site that’s harmless, but it isn’t needed either, so a ! in front of the name drops it.

The catch is script-src 'self': it blocks every inline script. My homepage had three small ones. I moved them into files and told Astro to stop inlining small scripts and assets into the HTML:

// astro.config.mjs
vite: {
	build: {
		assetsInlineLimit: 0,
	},
},

Styles are a different story. Code highlighting uses inline style attributes, so styles get 'unsafe-inline'. That’s a trade-off I’m fine with: injected styles are a lot less dangerous than injected scripts.

Testing is the tricky part. astro dev and astro preview don’t apply _headers, so a broken policy only shows up once it’s live. I ran a tiny local server that sends the same headers, loaded every page and watched for violations. Then I deliberately injected an inline script to make sure it actually gets blocked. If a test can’t fail, it’s not a test.

After deploying, check the real thing:

curl -sI https://example.com/ | grep -i content-security-policy

Use the https:// here. Without it, curl asks over plain HTTP and you only get Cloudflare’s redirect to HTTPS, which doesn’t carry your headers. I fell for that one myself.

One thing to know about HSTS: it tells browsers to only use HTTPS for your domain for as long as max-age says, here a year. Fine on Cloudflare, but don’t set it on a domain you might ever serve over plain HTTP.

The short version

  • Grep the build for source maps, absolute paths and your own name
  • Remove the generator tag and HTML comments
  • Check and strip image metadata
  • Look at every file in the output, not just the pages
  • Check what’s actually live, especially right after connecting a domain
  • Add security headers, and test the CSP with the headers actually applied

The build output is what the internet sees. Read it like an attacker would. It only takes a few minutes.