c1db5cec86
All JS moved to external /site.js → script-src 'self' with no inline JS, hashes or eval. Full header set via nginx (CSP, nosniff, frame-deny, referrer, permissions, COOP/CORP); HSTS stays at the CF edge. Shared headers include avoids the location add_header reset footgun. Build-time secret/inline-script/third-party scan gate. SECURITY.md documents posture.
66 lines
2.8 KiB
Markdown
66 lines
2.8 KiB
Markdown
# 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 `<meta>`) 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 `<script>` (would break the strict CSP)
|
|
- third-party `<script>`/`<link>` origins
|
|
|
|
Runs in CI before the image is built.
|
|
|
|
## Targets (verified post-deploy)
|
|
|
|
- securityheaders.com → A+
|
|
- Mozilla Observatory → A/A+ (small deduction expected for `style-src 'unsafe-inline'`)
|
|
- Lighthouse ≥ 95 across Performance / Accessibility / Best Practices / SEO
|
|
- CSP: zero console violations in a real browser (incl. code blocks + diagrams)
|
|
|
|
## Reporting
|
|
|
|
Found something? Email the address on the site.
|