diff --git a/Dockerfile b/Dockerfile index b280b5f..0f5a770 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,9 @@ RUN npm run build # Same vetted digest used by the k8s Deployment. Renovate keeps it current. FROM ghcr.io/nginx/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0 -# Custom server config (security headers, caching, SPA-ish routing). +# Custom server config (security headers, caching, routing) + shared headers include. COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY nginx/security-headers.conf /etc/nginx/security-headers.conf # The built site. COPY --from=build /app/dist /usr/share/nginx/html diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1e8645e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security posture + +This site is meant to demonstrate the discipline it advertises. Security is treated +as acceptance criteria, not polish. + +## Attack surface + +- **Static output** — no server runtime, no database, no user input, no forms. +- Served by `nginx-unprivileged` (uid 101, read-only root filesystem, all Linux + capabilities dropped, no service-account token) on Kubernetes. +- Exposed **outbound-only via a Cloudflare Tunnel** — no open inbound ports, a single + public hostname, no catch-all. + +## Headers + +Split by where they belong: + +**Origin (nginx, ships in the image — `nginx/security-headers.conf`)** + +| Header | Value | +|---|---| +| Content-Security-Policy | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests` | +| X-Content-Type-Options | `nosniff` | +| X-Frame-Options | `DENY` | +| Referrer-Policy | `strict-origin-when-cross-origin` | +| Permissions-Policy | camera/mic/geo/payment/usb… all denied | +| Cross-Origin-Opener-Policy | `same-origin` | +| Cross-Origin-Resource-Policy | `same-origin` | + +**Edge (Cloudflare dashboard)** — HSTS, HTTP→HTTPS redirect, SSL Full, Bot Fight, +rate-limiting. HSTS ships **without** `includeSubDomains`/`preload` initially because +`*.bztmon.com` resolves to the WAN and the subdomain form would brick non-public hosts. + +## CSP notes + +- **`script-src 'self'` with zero inline scripts.** All JS (pre-paint theme, toggle, + scroll reveal) lives in one external `/site.js`. No `unsafe-inline`, no `unsafe-eval`, + no hashes to maintain. +- **`style-src` allows `'unsafe-inline'`** — the one conscious exception. Shiki's + dual-theme syntax highlighting emits per-token CSS custom-properties as inline `style` + attributes; hashing them is impractical (they vary per page). The security-critical + directive (`script-src`) stays strict. Everything else is self-hosted: fonts are system + stacks (zero font requests), no third-party scripts, so SRI is moot. +- CSP is emitted as a real HTTP **header** (not ``) so `frame-ancestors` is honoured + and header scanners can see it. + +## Build-time gate + +`npm run scan` (`scripts/check-build.sh`) fails the build on: +- secret-like strings in `dist/` +- any inline ` + + diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 3aa82ea..bb4d47f 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -50,23 +50,10 @@ const ogImage = new URL(site.ogImage, site.url).href; - - + + @@ -75,26 +62,5 @@ const ogImage = new URL(site.ogImage, site.url).href;