commit 8f84de47ecb40d0fa19df3087342a2a481aab59e
parent 8c3c29f871e2a1420d9930177fe0806845eacaf7
Author: $: DAΞMON <zer0sec.xp@icloud.com>
Date: Wed, 30 Sep 2026 05:32:26 +0100
Send security headers from Cloudflare, and drop the eval the CSP forbids
The live host sent no security headers at all: no CSP, no frame
protection, no nosniff, no HSTS. public/_headers now carries the same
set daemon-sec serves, adjusted for what this site loads: WebAssembly
and a same-origin worker for Pagefind, inline scripts for the theme
and page-iris bootstraps, data: fonts, and https: images because the
imported sheets hotlink figures from dozens of hosts. The PDF the JJ
guide frames gets its own block, since frame-ancestors is judged on
the framed response and the site-wide DENY would blank the viewer.
The search modal built its Pagefind import through new Function to
keep Vite's dev server from rewriting it, which is an eval and the
new policy blocks it. That trick now runs in dev only; the build gets
a plain runtime-string import(), which Rollup leaves native, so no
eval reaches the bundle.
Tested: npm test (build + 4/4). Under wrangler dev with the built
dist, every response carried the headers, the PDF page framed its PDF,
and search through the modal returned results with zero CSP
violations on the page-load console.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 44 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -14,3 +14,6 @@ candidates.json
.env*
# fetch-ired.py research scratch, if ever pointed back inside the repo
.ired-cache/
+
+# wrangler dev scratch state
+.wrangler/
diff --git a/public/_headers b/public/_headers
@@ -0,0 +1,31 @@
+# Security headers for every static response Cloudflare serves. Workers
+# reads this file out of the asset directory; Astro copies public/ into
+# dist/ verbatim, so this is where it is authored. Format and limits:
+# developers.cloudflare.com/workers/static-assets/headers/
+#
+# Mirrors daemon-sec's staticSecurityHeaders() with three differences
+# this site needs: 'wasm-unsafe-eval' and a same-origin worker for the
+# Pagefind search index, 'unsafe-inline' scripts for the theme and page
+# iris bootstraps in Base.astro, and data: fonts for the icon face inlined
+# in the CSS. No script, style or font is loaded from another origin.
+# img-src is open to https: because the imported sheets hotlink figures from
+# dozens of hosts; frame-src 'self' is for the PDF viewer on the JJ guide.
+/*
+ Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'self'; frame-ancestors 'none'; worker-src 'self'; upgrade-insecure-requests
+ X-Frame-Options: DENY
+ X-Content-Type-Options: nosniff
+ Referrer-Policy: strict-origin-when-cross-origin
+ Cross-Origin-Opener-Policy: same-origin
+ Strict-Transport-Security: max-age=31536000; includeSubDomains
+ Permissions-Policy: camera=(), microphone=(), geolocation=(), browsing-topics=()
+ X-DNS-Prefetch-Control: off
+ X-Permitted-Cross-Domain-Policies: none
+
+# The JJ field guide page frames its own PDF. The response for the PDF
+# itself must allow that, or the viewer shows nothing: frame-ancestors is
+# judged on the framed document, not the page framing it. A PDF runs no
+# script, so everything else stays closed.
+/pdfs/*
+ ! X-Frame-Options
+ ! Content-Security-Policy
+ Content-Security-Policy: default-src 'none'; frame-ancestors 'self'
diff --git a/src/components/SearchModal.astro b/src/components/SearchModal.astro
@@ -312,9 +312,16 @@ import Icon from './Icon.astro';
never in source Vite can scan, so it emits a genuine browser-native
import that fetches pagefind untouched. Pagefind's own internal chunk
imports then resolve against its real URL and are served straight from
- `public/pagefind/` (or `dist/pagefind/` in prod). */
- const nativeImport: (u: string) => Promise<any> =
- new Function('u', 'return import(u)') as any;
+ `public/pagefind/` (or `dist/pagefind/` in prod).
+
+ Dev only. `new Function` is an eval, and the site's Content Security
+ Policy (public/_headers) allows no eval, so in the build the import is
+ written plainly: Rollup leaves a runtime-string `import()` native, and
+ `import.meta.env.DEV` is a literal there, so the eval branch is not in
+ the bundle at all. */
+ const nativeImport: (u: string) => Promise<any> = import.meta.env.DEV
+ ? (new Function('u', 'return import(u)') as any)
+ : (u: string) => import(/* @vite-ignore */ u);
function loadPagefind() {
if (loading) return loading;