M4: security pass — strict CSP, header split, build-time scan

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.
This commit is contained in:
2026-06-17 17:12:57 +10:00
parent cb76a87c36
commit c1db5cec86
9 changed files with 210 additions and 64 deletions
+2 -1
View File
@@ -18,8 +18,9 @@ RUN npm run build
# Same vetted digest used by the k8s Deployment. Renovate keeps it current. # Same vetted digest used by the k8s Deployment. Renovate keeps it current.
FROM ghcr.io/nginx/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0 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/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/security-headers.conf /etc/nginx/security-headers.conf
# The built site. # The built site.
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
+65
View File
@@ -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 `<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.
+12 -12
View File
@@ -11,28 +11,28 @@ server {
# Don't leak the nginx version. # Don't leak the nginx version.
server_tokens off; server_tokens off;
# ---- Security headers (origin) ------------------------------------------- # Security headers (CSP, nosniff, frame, etc.) — applied site-wide.
# These travel with the artifact. HSTS + HTTPS redirect are set at the # Re-included in each location below that sets its own add_header, because
# Cloudflare edge (the tunnel terminates TLS), so they are NOT set here. # a location-level add_header drops all inherited ones.
add_header X-Content-Type-Options "nosniff" always; include /etc/nginx/security-headers.conf;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
# ---- Content-Security-Policy ---------------------------------------------
# FILLED IN M4: Astro computes the inline script/style hashes at build; the
# final policy is emitted here as a header (a <meta> CSP can't set
# frame-ancestors). Until then, a conservative baseline:
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ---- Caching ------------------------------------------------------------- # ---- Caching -------------------------------------------------------------
# Astro emits content-hashed assets under /_astro/ — cache them hard. # Astro emits content-hashed assets under /_astro/ — cache them hard.
location /_astro/ { location /_astro/ {
include /etc/nginx/security-headers.conf;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable" always; add_header Cache-Control "public, immutable" always;
} }
# Non-fingerprinted top-level script — revalidate so updates propagate.
location = /site.js {
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always;
}
# HTML is revalidated so deploys show up immediately. # HTML is revalidated so deploys show up immediately.
location ~* \.html$ { location ~* \.html$ {
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always; add_header Cache-Control "no-cache" always;
} }
+14
View File
@@ -0,0 +1,14 @@
# Shared security headers. `include`d in the server block AND in every location
# that sets its own add_header (because add_header in a location REPLACES, not
# merges, inherited headers — the classic nginx footgun).
#
# HSTS + HTTP->HTTPS redirect are set at the Cloudflare edge (TLS terminates
# there), so they are intentionally NOT here.
add_header 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" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
+1
View File
@@ -10,6 +10,7 @@
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check", "check": "astro check",
"scan": "bash scripts/check-build.sh dist",
"gen:og": "node scripts/gen-og.mjs", "gen:og": "node scripts/gen-og.mjs",
"astro": "astro" "astro": "astro"
}, },
+59
View File
@@ -0,0 +1,59 @@
/* Single external script (no inline JS anywhere → strict script-src 'self').
Loaded blocking in <head> so the theme is set before first paint (no flash). */
(function () {
// --- Pre-paint theme -----------------------------------------------------
try {
var stored = localStorage.getItem("theme");
var theme =
stored ||
(window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
document.documentElement.dataset.theme = theme;
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
function onReady(fn) {
if (document.readyState !== "loading") fn();
else document.addEventListener("DOMContentLoaded", fn);
}
onReady(function () {
// --- Theme toggle ------------------------------------------------------
var btn = document.getElementById("theme-toggle");
if (btn) {
btn.addEventListener("click", function () {
var root = document.documentElement;
var next = root.dataset.theme === "light" ? "dark" : "light";
root.dataset.theme = next;
try {
localStorage.setItem("theme", next);
} catch (e) {
/* ignore */
}
});
}
// --- Reveal on scroll (progressive enhancement, motion-aware) ----------
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var targets = document.querySelectorAll("[data-reveal]");
if (!reduce && "IntersectionObserver" in window) {
targets.forEach(function (el) {
el.classList.add("reveal");
});
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
});
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
targets.forEach(function (el) {
io.observe(el);
});
}
});
})();
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Build-time security scan of dist/ (§8 evidence). Fails the build if it finds:
# - secrets / private keys / tokens
# - inline <script> blocks (we require strict CSP script-src 'self')
# - third-party script/style/font origins (everything must be self-hosted)
# Run after `astro build`. Exit non-zero on any finding.
set -euo pipefail
DIST="${1:-dist}"
fail=0
note() { printf ' %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
echo "== secret scan =="
# Allow our own public email + domains; look for genuinely secret-looking patterns.
if grep -rniE 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|aws_secret|api[_-]?key["'"'"' :=]|secret[_-]?key["'"'"' :=]|password["'"'"' :=]|bearer [a-z0-9._-]{20}' "$DIST" 2>/dev/null; then
bad "possible secret found in dist/"
else
note "✓ no secret-like strings"
fi
echo "== inline <script> (must be none; CSP script-src 'self') =="
# An inline script is <script> WITHOUT a src= attribute.
if grep -rnoE '<script(>| [^>]*>)' "$DIST" --include='*.html' | grep -vE 'src=' >/dev/null; then
grep -rnoE '<script(>| [^>]*>)' "$DIST" --include='*.html' | grep -vE 'src=' | head
bad "inline <script> present"
else
note "✓ no inline scripts"
fi
echo "== third-party resource origins (must be self-hosted) =="
# Flag <script src> / <link href> tags pointing off-domain (anchor <a href> links
# in the body are fine). Match the tag + attr together so e.g. linkedin.com in an
# <a> isn't a false positive.
if grep -rioE '<(script|link)\b[^>]*\b(src|href)="https?://[^"]+"' "$DIST" --include='*.html' \
| grep -ivE '"https?://(www\.|git\.)?bztmon\.com' >/dev/null 2>&1; then
echo " offending tags:"
grep -rioE '<(script|link)\b[^>]*\b(src|href)="https?://[^"]+"' "$DIST" --include='*.html' \
| grep -ivE '"https?://(www\.|git\.)?bztmon\.com' | head
bad "third-party script/style origin referenced"
else
note "✓ no third-party script/style origins (external links in body are fine)"
fi
echo
if [[ "$fail" -ne 0 ]]; then
echo "BUILD SCAN: FAIL"
exit 1
fi
echo "BUILD SCAN: PASS"
+2 -13
View File
@@ -62,16 +62,5 @@
} }
</style> </style>
<script> <!-- Toggle behaviour lives in /public/site.js (no inline JS → strict CSP). -->
const btn = document.getElementById("theme-toggle");
btn?.addEventListener("click", () => {
const root = document.documentElement;
const next = root.dataset.theme === "light" ? "dark" : "light";
root.dataset.theme = next;
try {
localStorage.setItem("theme", next);
} catch (e) {
/* ignore */
}
});
</script>
+4 -38
View File
@@ -50,23 +50,10 @@ const ogImage = new URL(site.ogImage, site.url).href;
<meta name="theme-color" content="#090c14" /> <meta name="theme-color" content="#090c14" />
<!-- Pre-paint theme bootstrap: avoids a flash of the wrong theme. <!-- Single external script: pre-paint theme + toggle + scroll reveal.
The ONE inline script on the site; hashed by Astro CSP in M4. --> Blocking in <head> so the theme is set before first paint (no flash).
<script is:inline> No inline JS anywhere on the site → strict CSP script-src 'self'. -->
(function () { <script is:inline src="/site.js"></script>
try {
var stored = localStorage.getItem("theme");
var theme =
stored ||
(window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark");
document.documentElement.dataset.theme = theme;
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
</head> </head>
<body> <body>
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>
@@ -75,26 +62,5 @@ const ogImage = new URL(site.ogImage, site.url).href;
<slot /> <slot />
</main> </main>
<Footer /> <Footer />
<!-- Reveal-on-scroll: progressive enhancement, motion-aware. -->
<script>
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targets = document.querySelectorAll<HTMLElement>("[data-reveal]");
if (!reduce && "IntersectionObserver" in window) {
targets.forEach((el) => el.classList.add("reveal"));
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
targets.forEach((el) => io.observe(el));
}
</script>
</body> </body>
</html> </html>