Before flipping the switch to prod I ran a two-pass review over the site: an explorer agent mapped the codebase, then a reviewer agent went hunting for security and production-readiness issues. That second pass turned up two real blockers, a handful of high-severity items, and a few smaller things worth bundling in. This post walks through what shipped.
Critical fixes
The site’s own CSP was blocking its own fonts
Base.astro was loading Roboto Mono from fonts.googleapis.com and fonts.gstatic.com, but the CSP emitted by the middleware was style-src 'self' 'unsafe-inline'; font-src 'self'. Neither Google origin was whitelisted, which meant any CSP-enforcing browser (all of them) would block the stylesheet and fall back to whatever local monospace resolved to. That’s the whole visual identity of the site gone.
I had two options: whitelist the Google origins in CSP, or self-host. Self-hosting is the stronger answer obviously, one fewer third-party request, no cross-origin hop, CSP stays tight. I pulled all six woff2 subsets that the Google stylesheet was serving, dropped them under public/fonts/roboto-mono/, and added the @font-face block to global.css with matching unicode-range values for each subset so the browser only downloads what it actually needs. The <link> tags to the Google origins are gone from Base.astro. CSP didn’t have to change at all.
No Vary: User-Agent anywhere
The whole site hinges on a UA-sniffing middleware: curl lsalik.dev gets ANSI; a browser gets HTML. That’s the entire value prop of the curl feature. But no response was emitting Vary: User-Agent. Any proxy that honors Vary but doesn’t honor Cache-Control: no-store (corporate proxies, shared squids, a future CDN tier change) could happily serve HTML to a curl client or a terminal blob to a browser. Vercel’s edge cache doesn’t honor Vary: User-Agent today, but relying on that is fragile.
I moved SECURITY_HEADERS out of middleware.ts and into src/lib/security-headers.ts. A plain module, no Astro-runtime dependency, so it’s unit-testable. Vary: User-Agent is now the first entry, applied via withHeaders() to every response: curl output, HTML output, 404s, the sitemap/robots short-circuits, and the /links redirect. There’s a new tests/middleware.test.ts that asserts both UAs get the header.
High-severity fixes
Anchor the UA match
The detector was using .includes():
const TERMINAL_AGENTS = ['curl/', 'wget/', 'httpie/', 'fetch/', 'libfetch/'];
return TERMINAL_AGENTS.some(agent => ua.toLowerCase().includes(agent));
curl/ can appear anywhere in a UA string. Any bot advertising MyScanner/1.0 (curl/8-based), a browser extension spoofing something weird, a Referer bleed into the UA header — all of those get plain text instead of HTML. Not a security bug per se, but definitely a false-positive surface.
Product tokens are always the first token in a real UA, so I anchored the regex:
return /^(curl|wget|httpie|fetch|libfetch)\//i.test(ua.trim());
curl/8.4.0 still matches. Mozilla/5.0 (compatible; thing/curl/8) no longer does.
ANSI escape injection from markdown bodies
sanitizePathnameForTerminal() has been stripping ESC from 404 paths for a while. But the markdown body content (entry.body) and frontmatter description (entry.data.description) for blog and project pages were being interpolated straight into the curl output with no sanitization. A post author (future contributor, compromised repo) could embed \x1b[2J (clear screen), \x1b]8;;https://evil\x07link\x1b]8;;\x07 (OSC 8 hyperlinks), or iTerm2 OSC 1337 escapes and they’d render live in any curl reader’s terminal. The blast radius is small — it’s author-controlled content — but the inconsistency with the pathname sanitizer was the actual bug.
I added a stripDangerousEscapes() helper in src/curl/ansi.ts that takes the opposite approach from stripAnsi(): instead of stripping everything, it keeps SGR (the \x1b[...m color/style codes) and strips:
- OSC strings (
ESC ]throughBELorESC \), - non-SGR CSI sequences (
ESC [with a final byte that isn’tm), - bare ESC + single-char sequences (
ESC c, etc.).
That helper is applied at the boundary in middleware.ts before content enters the render functions, so all four surfaces — blog post body, project post body, and both index-page descriptions — are covered. Tests in ansi.test.ts cover SGR passthrough, OSC hyperlink removal, screen-clear removal, iTerm2 OSC 1337, and mixed content.
Auditing the path-to-regexp override
package.json had a forced override pinning path-to-regexp to ^6.3.0, due to CVE-2024-45296 (a ReDoS). I wanted to know if it was still necessary or just stale config rotting in the tree. npm ls path-to-regexp showed @vercel/routing-utils@5.3.3 still requesting path-to-regexp@6.1.0, which is the vulnerable version. So the override is load-bearing. I kept it and added a code comment citing the CVE and the dep that pulls in the unpatched version, so a code agent or an open-source contributor knows exactly when it can come out.
Medium hardening
Hash-pin the one inline script
The CSP had script-src 'self' 'unsafe-inline' for one reason: a five-line palette-restore IIFE that runs before first paint to avoid a theme flash. 'unsafe-inline' is a big hammer for one small static script. I computed its SHA-256 and swapped the directive:
script-src 'self' 'sha256-OVMxEOIbYL7kzB5+NR2bhY5aqbo5+Dk1R68D+NM/8iE='
The script runs, CSP doesn’t tolerate any other inline script anymore, and the XSS surface is tighter by one directive.
I left style-src 'unsafe-inline' in place for now. Astro injects inline component styles at build time and the hashes aren’t stable across builds; solving that properly needs a nonce per response, which is a bigger change for marginal benefit on a static-content site.
Pin the Node engine
Added "engines": { "node": ">=20.0.0" } to package.json. Vercel’s default Node version drifts; an implicit bump could break the adapter or ESM assumptions silently. Now it’s declared.
Low-severity cleanup
Unicode controls in 404 paths. The pathname sanitizer stripped [\x00-\x1f\x7f], which kills ESC, great and all but left C1 controls (\x80-\x9f), BiDi overrides (U+202A-\u202E, U+2066-\u2069), and zero-width spaces alone. A path like /ɛvil\u202e... would render right-to-left in the 404 box. Cosmetic, not exploitable without ESC, but one regex change fixed it.
Real 404s for missing slugs. Both blog/[...slug].astro and projects/[...slug].astro were redirecting to their index page on a miss. That’s observability-hostile: search engines index and follow the redirect, and legitimately-gone URLs don’t signal gone. Both now return new Response(null, { status: 404 }).
Edit (04/18/2026): The hash-pin above broke in production. Astro’s build pipeline inlines small hoisted <script type="module"> tags directly into the HTML. ascii-bg.ts, palette-toggle.ts, curl-demo.ts, and the code-copy script on blog pages all ended up inlined. Their hashes weren’t in the CSP, so browsers silently blocked them. The ASCII background animation was the visible symptom.
The fix moved CSP generation out of a static header map and into a per-response step in the middleware. On any text/html response, the middleware now reads the body, regex-scans for every inline <script> without a src attribute, SHA-256 hashes each one via WebCrypto, and builds a script-src whose hash list exactly matches what’s in the HTML. src/lib/security-headers.ts was split into BASE_SECURITY_HEADERS (static headers) plus two helpers: inlineScriptHashes(html) and buildCsp(scriptHashes). A new withHtmlHeaders wrapper handles the read-hash-rebuild cycle.
Tradeoff: the response body is consumed and re-emitted as a string rather than streamed, which is fine for the small pages this site serves. The security posture is unchanged from the original static-hash design; it’s the same whitelist principle, just auto-derived per response.