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:
+2
-1
@@ -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
|
||||
|
||||
+65
@@ -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
@@ -11,28 +11,28 @@ server {
|
||||
# Don't leak the nginx version.
|
||||
server_tokens off;
|
||||
|
||||
# ---- Security headers (origin) -------------------------------------------
|
||||
# These travel with the artifact. HSTS + HTTPS redirect are set at the
|
||||
# Cloudflare edge (the tunnel terminates TLS), so they are NOT set here.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
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;
|
||||
# Security headers (CSP, nosniff, frame, etc.) — applied site-wide.
|
||||
# Re-included in each location below that sets its own add_header, because
|
||||
# a location-level add_header drops all inherited ones.
|
||||
include /etc/nginx/security-headers.conf;
|
||||
|
||||
# ---- Caching -------------------------------------------------------------
|
||||
# Astro emits content-hashed assets under /_astro/ — cache them hard.
|
||||
location /_astro/ {
|
||||
include /etc/nginx/security-headers.conf;
|
||||
expires 1y;
|
||||
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.
|
||||
location ~* \.html$ {
|
||||
include /etc/nginx/security-headers.conf;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -10,6 +10,7 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check",
|
||||
"scan": "bash scripts/check-build.sh dist",
|
||||
"gen:og": "node scripts/gen-og.mjs",
|
||||
"astro": "astro"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
Executable
+51
@@ -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"
|
||||
@@ -62,16 +62,5 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
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>
|
||||
<!-- Toggle behaviour lives in /public/site.js (no inline JS → strict CSP). -->
|
||||
|
||||
|
||||
@@ -50,23 +50,10 @@ const ogImage = new URL(site.ogImage, site.url).href;
|
||||
|
||||
<meta name="theme-color" content="#090c14" />
|
||||
|
||||
<!-- Pre-paint theme bootstrap: avoids a flash of the wrong theme.
|
||||
The ONE inline script on the site; hashed by Astro CSP in M4. -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
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>
|
||||
<!-- Single external script: pre-paint theme + toggle + scroll reveal.
|
||||
Blocking in <head> so the theme is set before first paint (no flash).
|
||||
No inline JS anywhere on the site → strict CSP script-src 'self'. -->
|
||||
<script is:inline src="/site.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
@@ -75,26 +62,5 @@ const ogImage = new URL(site.ogImage, site.url).href;
|
||||
<slot />
|
||||
</main>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user