mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Security pass
This commit is contained in:
+133
-2
@@ -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-<value>'` 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user