From f559541c6eed18d85970ac6af37e65c906728ff6 Mon Sep 17 00:00:00 2001 From: Greg Kilmer Date: Sat, 11 Jul 2026 17:10:44 -0400 Subject: [PATCH] Security pass --- docs/DEPLOYMENT.md | 135 +++++++++++++++++++- public/.well-known/security.txt | 9 ++ src/components/professional/ContactForm.tsx | 8 ++ src/middleware.ts | 91 +++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 public/.well-known/security.txt create mode 100644 src/middleware.ts diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ae377e0..05703a5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -189,7 +189,115 @@ The site applies a layered anti-scraping policy: --- -## 5. Updating the site +## 5. Security headers & hardening + +Security is split between the app (version-controlled) and the reverse proxy +(owns the TLS edge). This division keeps each part where it's strongest. + +### Set in the app — `src/middleware.ts` + +Every response carries these headers, applied by Astro middleware: + +| Header | Value | Purpose | +| --- | --- | --- | +| `Content-Security-Policy` | see file | Restricts script/style/font/img/connect/frame sources | +| `X-Content-Type-Options` | `nosniff` | Blocks MIME-sniffing | +| `X-Frame-Options` | `DENY` | Clickjacking (legacy browsers; CSP `frame-ancestors 'none'` covers modern ones) | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limits referrer leakage | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), interest-cohort=()` | Disables unused browser features | + +The CSP allows `'self'`, `'unsafe-inline'` (temporary — see below), and +`https://challenges.cloudflare.com` (Cloudflare Turnstile, used only on +`/contact`). All fonts, images, and styles are self-hosted, so nothing else +needs an external origin. + +### Set at the reverse proxy (Nginx Proxy Manager) + +These belong at the proxy because they only make sense at the TLS edge, or +because only the proxy can overwrite client-supplied headers: + +- **HTTP → HTTPS redirect** (Force SSL / `return 301 https://$host$request_uri`). +- **`Strict-Transport-Security`** (HSTS) — ignored over HTTP, so the proxy must + set it. Example: `max-age=31536000; includeSubDomains`. Leave `preload` off + until you've run the site a while and confirmed all subdomains serve HTTPS. +- **`X-Forwarded-For` overwrite** — **critical for the contact API's rate + limiter.** Add to the proxy host's Custom Nginx Configuration: + ```nginx + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + ``` + The `X-Forwarded-For $remote_addr` line **overwrites** (not appends) any + client-supplied value, closing a rate-limit bypass where an attacker spoofs + a fresh IP per request. (`/api/contact` reads the first XFF hop as the + client IP.) + +### CSRF — not applicable, intentionally + +The contact endpoint needs no CSRF token because none of the standard CSRF +preconditions hold: + +- **No cookies or sessions** — there is no auth and no session state to forge + against. +- **JSON-only body** — the handler calls `request.json()`; a cross-origin form + POST (the classic CSRF vector) sends form-encoded data, which fails JSON + parsing and is rejected with 400 before any side effect. +- **No CORS** — the endpoint has no permissive `Access-Control-Allow-Origin`, + so a cross-origin `fetch` with JSON from a malicious site is blocked by the + browser's same-origin policy unless the request is "simple" (a JSON POST + with `Content-Type: application/json` is not simple and triggers a CORS + preflight that fails). + +If any of these change (auth added, a form-encodable fallback, CORS opened), +revisit CSRF here. + +### Rate-limiting caveats + +- The limiter in `/api/contact` is **per-worker** (in-memory). For a single + container behind the proxy this is fine. If you ever scale to multiple + replicas, swap the in-memory `Map` for Redis/Upstash so the limit is global. +- The IP is taken from `X-Forwarded-For`, which is why the proxy XFF overwrite + above matters. +- The proxy can additionally rate-limit at the edge (`limit_req`) for defense + before traffic reaches Node. Optional — the app already limits. + +### Dependency hygiene + +There is no CI pipeline; deploys are manual. Before each deploy, run: + +```bash +npm audit # report known vulns in the dependency tree +npm audit --omit=dev # production deps only +npm outdated # check for behind-version packages +``` + +Address any `high`/`critical` audit findings before building. `npm update` +within the caret ranges in `package.json` is safe between full upgrades. + +### `/.well-known/security.txt` + +A [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116) security contact lives at +`public/.well-known/security.txt`, served at +`https://gregkilmer.com/.well-known/security.txt`. It points to +`security@gregkilmer.com` and expires ~1 year out — **renew the `Expires:` +date before it passes**, or vulnerability reporters' tools will flag it as +stale. + +### Future: tighten CSP with nonces (deferred) + +`script-src` currently includes `'unsafe-inline'` because two inline scripts +in `BaseLayout.astro` (the theme-init boot and the View Transitions +flash-killer) can't be nonce-tagged without restructuring how they're emitted. +The next step is to generate a per-request nonce in `src/middleware.ts`, inject +it onto those two scripts in `BaseLayout.astro`, and replace `'unsafe-inline'` +with `'nonce-'` in `script-src`. Astro View Transitions can complicate +nonce propagation across the client-side swap, so this is its own task — the +file header in `src/middleware.ts` tracks it. + +--- + +## 6. Updating the site ```bash git pull @@ -201,7 +309,7 @@ the new artifact with zero config. --- -## 6. Without Docker (bare Node) +## 7. Without Docker (bare Node) If you prefer to run Node directly on the host: @@ -212,3 +320,26 @@ HOST=0.0.0.0 PORT=4321 SITE_URL=https://gregkilmer.com node ./dist/server/entry. ``` Run it under a process manager (systemd, pm2) so it restarts on crash/boot. + +--- + +## 8. Verifying security headers after deploy + +```bash +# App-set headers (from src/middleware.ts) +curl -sI https://gregkilmer.com | grep -iE 'content-security|x-frame|x-content|referrer|permissions' + +# Proxy-set HSTS + redirect (Nginx Proxy Manager) +curl -sI https://gregkilmer.com | grep -i 'strict-transport' +curl -sI http://gregkilmer.com # expect 301 -> https:// + +# security.txt reachable +curl -s https://gregkilmer.com/.well-known/security.txt +``` + +All five app headers (`content-security-policy`, `x-frame-options`, +`x-content-type-options`, `referrer-policy`, `permissions-policy`) should be +present on every route, including `/api/contact`. The `strict-transport- +security` header should appear only on the HTTPS response, and the plain-HTTP +request should redirect. + diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..ea34e01 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,9 @@ +# Security contact for gregkilmer.com. +# Spec: https://www.rfc-editor.org/rfc/rfc9116 +# Reachable at https://gregkilmer.com/.well-known/security.txt +# Renew Expires before the date below (set ~1 year out). + +Contact: mailto:security@gregkilmer.com +Expires: 2027-07-11T00:00:00.000Z +Preferred-Languages: en +Canonical: https://gregkilmer.com/.well-known/security.txt diff --git a/src/components/professional/ContactForm.tsx b/src/components/professional/ContactForm.tsx index 8bf068e..edf3cbc 100644 --- a/src/components/professional/ContactForm.tsx +++ b/src/components/professional/ContactForm.tsx @@ -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); } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..7beee0d --- /dev/null +++ b/src/middleware.ts @@ -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