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.
|
# 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
@@ -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.
|
# 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
</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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user