Security pass

This commit is contained in:
Greg Kilmer
2026-07-11 17:10:44 -04:00
parent 810c8281d4
commit f559541c6e
4 changed files with 241 additions and 2 deletions
@@ -79,6 +79,14 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
// No SRI `integrity` attribute here — deliberate. Cloudflare rotates the
// Turnstile loader contents (so a pinned hash would break the challenge
// on rotation), the loader itself fetches further challenge resources
// that SRI can't cover, and Cloudflare does not publish official SRI
// hashes. SRI on Turnstile is industry-standard NOT to use; the risk is
// mitigated by serving the site over HTTPS and by the CSP (which
// restricts script-src to this one Cloudflare origin). See
// src/middleware.ts.
document.head.appendChild(script);
}
+91
View File
@@ -0,0 +1,91 @@
/**
* middleware.ts
* ---------------------------------------------------------------------------
* Sets HTTP security headers on every response. Version-controlled so the
* policy travels with deploys instead of living only in the reverse proxy.
*
* Division of responsibility (hybrid model):
* - THIS FILE: content headers that must stay in sync with the app
* (CSP especially — its allowed origins depend on what the app loads).
* - REVERSE PROXY (Nginx Proxy Manager): HSTS + the HTTP->HTTPS redirect +
* X-Forwarded-For sanitization. HSTS belongs at the TLS edge because it is
* ignored over HTTP, and the proxy is the only hop that can overwrite
* client-supplied XFF values (so the contact API's per-IP rate limiter
* can't be spoofed).
*
* CSP is intentionally permissive on this first pass: `script-src` allows
* 'unsafe-inline' because two inline <script is:inline> blocks in
* BaseLayout.astro (the theme-init boot at line ~104 and the View Transitions
* flash-killer at line ~118) can't be nonce-tagged without restructuring how
* they're emitted. FUTURE WORK: generate a per-request nonce in this
* middleware (set it in locals, inject it onto those two scripts in
* BaseLayout via a nonce attribute, and replace 'unsafe-inline' with 'nonce-…'
* in script-src). Astro View Transitions can complicate nonce propagation
* across the client-side swap, so that migration is its own task — tracked
* here so 'unsafe-inline' is known to be temporary.
*
* Allowed origins:
* - challenges.cloudflare.com — Cloudflare Turnstile, used only on /contact.
* The Turnstile loader (api.js) is injected dynamically by ContactForm.tsx
* and renders its challenge inside an iframe, so script/frame/connect all
* need the origin. (SRI is intentionally NOT applied to the Turnstile
* loader — see ContactForm.tsx for why.)
*
* Everything else is 'self': fonts are self-hosted (Fontsource woff2), images
* are self-hosted (board-game covers fetched at build time, local hero
* portrait), and there is no analytics/tracking.
*/
import { defineMiddleware } from 'astro:middleware';
/**
* The full Content-Security-Policy. Kept as a single string so it reads as a
* spec; tighten individual directives here as the app hardens.
*/
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
// 'self' = Astro-bundled scripts. 'unsafe-inline' = the two inline blocks in
// BaseLayout (see file header) — temporary, to be replaced by a nonce.
// Turnstile origin = its loader + challenge runtime.
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
// Astro/Tailwind inject inline styles at runtime, so 'unsafe-inline' stays
// for now. Tightening this requires hashing the generated styles.
"style-src 'self' 'unsafe-inline'",
// All images self-hosted; data: covers inlined SVG / data-URI usage.
"img-src 'self' data:",
// Self-hosted variable fonts (woff2) via @fontsource-variable.
"font-src 'self'",
// Same-origin fetch/XHR (the contact form POSTs to /api/contact) plus the
// Turnstile siteverify calls the widget makes from the browser.
"connect-src 'self' https://challenges.cloudflare.com",
// Turnstile renders its challenge inside an iframe from this origin.
"frame-src https://challenges.cloudflare.com",
// Hard kill-switches — none of these are used by the app.
"object-src 'none'",
"base-uri 'self'",
// Forms may only submit to this origin.
"form-action 'self'",
// Clickjacking defense (X-Frame-Options: DENY covers legacy browsers too).
"frame-ancestors 'none'",
].join('; ');
/** Security headers applied to every response. HSTS lives in the proxy. */
const SECURITY_HEADERS: Record<string, string> = {
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Deny features the site never uses. interest-cohort=() also opts out of
// FLoC-style cohort-based ad tech.
'Permissions-Policy':
'camera=(), microphone=(), geolocation=(), interest-cohort=()',
};
export const onRequest = defineMiddleware(async (_context, next) => {
const response = await next();
// Set headers on the response. Using .headers.set means we overwrite any
// stray value rather than appending duplicates.
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(key, value);
}
return response;
});