CSP A+ on Hugo + Cloudflare: from hash-based to origin allowlist, auto-monitoring and hardening

Context
arleo.eu runs on Hugo (KVM VM) → OpenResty (NUC) → Cloudflare (CDN/WAF). Goal: A+ score on Mozilla Observatory with a strict CSP that resists edge-side injections.
The journey went through three strategies over a few weeks — nonces, hashes, then origin allowlist — before landing on a stable, automated solution.
Act 1: why hash-based failed
The initial idea seemed solid: Hugo Pipes externalizes all JS with SRI, we list the hashes in the CSP, clean result. In practice, two problems made the approach impossible.
Cloudflare injects after our nginx
Cloudflare’s Challenge Platform script (__CF$cv$params) is injected at the edge, after the NUC has forwarded the response. It contains per-request dynamic values (r:, t:) that change on every call — impossible to hash server-side.
Empirical proof:
# Via NUC direct (bypass CF) → 0 CF script
curl -sk --resolve www.arleo.eu:443:127.0.0.1 https://www.arleo.eu/ | grep -c '__CF\$cv'
# → 0
# Via Cloudflare edge → 1 injected script
curl -sk https://www.arleo.eu/ | grep -c '__CF\$cv'
# → 1A Lua body_filter_by_lua_block filter was even attempted to intercept the injection at nginx level — but beyond a syntax bug (Lua patterns vs PCRE NGINX), the approach was fundamentally wrong: nginx only sees the origin response, not the response modified by the Cloudflare edge.
Solution: disable CF JS Detections via API
Cloudflare doesn’t document this endpoint well, but the community found the solution:
curl -X PUT "https://api.cloudflare.com/client/v4/zones/${CF_ZONE}/bot_management" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"enable_js": false, "fight_mode": false}'Confirmed result: enable_js: false, fight_mode: false, no more injection.
Act 2: the CSP origin allowlist
Without CF injection, we can build a strict zero-inline CSP. Each directive is justified:
| Directive | Value | Reason |
|---|---|---|
default-src | 'none' | Block everything by default |
script-src | 'self' cdn.jsdelivr.net challenges.cloudflare.com *.cloudflareinsights.com | Hugo Pipes (self) + Mermaid/libs CDN + CF Turnstile + analytics |
script-src-attr | 'unsafe-hashes' 'unsafe-inline' | LoveIt event handlers (onclick) |
worker-src | 'self' cdn.jsdelivr.net | Mermaid v11 uses Web Workers |
style-src | 'self' 'unsafe-inline' cdn.jsdelivr.net | LoveIt inline styles |
frame-ancestors | 'self' | Anti-clickjacking |
object-src | 'none' | Block Flash/plugins |
upgrade-insecure-requests | — | Force HTTPS |
Score achieved: A+ 130/100 on Mozilla Observatory. The worker-src was the missing directive that was breaking Mermaid rendering — Mermaid v11 runs its parsers in Web Workers.
Act 3: csp-monitor.sh — automatic detection and repair
A */15 * * * * cron script monitors the entire chain and automatically repairs regressions.
Rollback tested under real conditions
The rollback mechanism was validated: deliberate vhost corruption → openresty -t fails → script restores last known-good backup → systemctl reload openresty → site back online. Empty diff between pre- and post-cycle state.
Act 4: Brooks-Lint hardening
A code review identified 11 findings. All applied:
C1 — Wildcard in sudoers (critical)
The initial rule allowed globs in sudo cp paths — symlink attack vector. Replaced with two root-owned wrappers with no parameters:
# /usr/local/bin/csp-backup.sh — no exposed wildcards
cp "$VHOST" "$BACKUP_DIR/www.arleo.eu.bak-$(date +%Y%m%d_%H%M%S)"
# /usr/local/bin/csp-restore.sh — restores only the last .bak-*
latest=$(ls -t "$BACKUP_DIR"/www.arleo.eu.bak-* | head -1)
cp "$latest" "$VHOST"Sudoers reduced to: NOPASSWD: /usr/bin/openresty -t, /usr/bin/openresty, /bin/systemctl reload openresty, /usr/local/bin/csp-backup.sh, /usr/local/bin/csp-restore.sh
I5 — CF token in plaintext
The Cloudflare token is encrypted with age (available in Ubuntu 24.04 via apt):
age-keygen -o /var/lib/csp-monitor/age-key.txt
echo -n "$CF_TOKEN" | age -r "$AGE_PUBKEY" -o /var/lib/csp-monitor/cf-token.ageOn-the-fly decryption in the script:
cf_token=$(age -d -i "$AGE_KEY_FILE" "$CF_TOKEN_AGE")Other findings
| Finding | Fix |
|---|---|
| I1 lockfile | flock -n 9 /run/lock/csp-monitor.lock |
| I2 Mermaid findRE too broad | exact class="mermaid" → avoids mermaid-extra |
| I3 Mermaid SRI hardcoded | resources.GetRemote $mermaidURL → hash computed at build |
I4 awk|wc -l pipefail | || echo 0 on all pipelines |
| M1 known-patterns.txt unbounded | FIFO cap at 1000 lines |
| M2 redundant canonical check | CF repair only triggered if directive missing |
| M3 inline script regex too broad | Excludes type="module" and type="application/json" |
| M4 no heartbeat | curl $BETTERSTACK_HEARTBEAT at end of successful run |
M5 mkdir -p no permissions | chmod 700 $STATE_DIR after creation |
Act 5: associated bugs found along the way
anti-flash.js — TypeError null body
The anti-flash script is loaded in <head> without defer (that’s the point: it must run before rendering to avoid theme flash). Problem: document.body is null at that point.
Old code:
document.body.setAttribute('theme', d ? 'dark' : 'light');
// → TypeError: Cannot read properties of null (reading 'setAttribute')
Fix: use document.documentElement immediately, then mirror to document.body via DOMContentLoaded:
const v = d ? 'dark' : 'light';
document.documentElement.setAttribute('theme', v);
document.documentElement.setAttribute('cfg-theme', t);
if (document.body) {
document.body.setAttribute('theme', v);
document.body.setAttribute('cfg-theme', t);
}document.documentElement (the <html> tag) is always available, including during <head> parsing. Selectors like [theme=dark] header use an ancestor match — the header is correct immediately. But body[theme=dark] requires the attribute directly on body: the white background persists until DOMContentLoaded. This residual bug is fixed in Act 6.
javascript:void(0) — CSP script-src-elem violations
The LoveIt theme uses href="javascript:void(0);" for toggle buttons (theme-switch, search, language). With a CSP script-src-elem without 'unsafe-inline', navigations to a javascript: URL are blocked.
Fix: override layouts/_partials/header.html with href="#" onclick="return false;". The onclick= event handlers are covered by script-src-attr 'unsafe-inline', and return false prevents scroll-to-#.
Hugo minifies automatically to onclick=return!1 — valid.
Act 6: second-pass corrections (next day)
Body flash — the Act 5 fix was incomplete
The anti-flash fix from Act 5 covered the header but not the body background. Two CSS selectors coexist in LoveIt with different behaviours:
| CSS selector | Mechanism | Result at first paint |
|---|---|---|
[theme=dark] header { ... } | Ancestor — matches as soon as html has the attribute | Correct immediately ✓ |
body[theme=dark] { background-color: #292a2d } | Direct — requires the attribute on body itself | White background until DOMContentLoaded ✗ |
Observable symptom: dark header correct, body shows white background for ~200 ms on every navigation.
Fix: add an ancestor selector for the body in assets/css/_custom.scss:
html[theme=dark] body {
background-color: #292a2d;
color: #a9a9b3;
}Specificity (0,1,2) > body {} (0,0,1) → applies from first paint, before any JavaScript. body[theme=dark] takes over after DOMContentLoaded with identical values, no regression.
Mermaid SRI — empty integrity without Fingerprint
resources.GetRemote alone does not compute the SRI hash — .Data.Integrity stays empty, resulting in integrity="" in the HTML.
Fix: pipe through resources.Fingerprint "sha256" in layouts/_partials/assets.html:
{{- $mermaidRes := resources.GetRemote $mermaidURL -}}
{{- $mermaidFP := $mermaidRes | resources.Fingerprint "sha256" -}}
<script src="{{ $mermaidURL }}" integrity="{{ $mermaidFP.Data.Integrity }}" crossorigin="anonymous" defer></script>resources.GetRemote downloads the file; resources.Fingerprint computes the hash. Both steps are required.
Observatory score — why it fluctuates
Two URLs scanned, two results:
| URL scanned | CF-Cache-Status | Score |
|---|---|---|
www.arleo.eu (direct) | HIT | 140 ✓ |
arleo.eu → www.arleo.eu (redirect) | BYPASS | 120 ✗ |
When Observatory starts from arleo.eu (bare domain), CF sometimes serves a BYPASS (uncached) response with stale headers. This is not the reference score — always scan www.arleo.eu directly.
Initial additional cause: CF Bot Fight Mode was intercepting the Observatory scanner and serving it a Cloudflare challenge page with its own minimal CSP (default-src 'none'; style-src 'unsafe-inline'; script-src https://challenges.cloudflare.com ...) — which explained the 120 scores before our intervention. Fix: add or (http.user_agent contains "http-observatory") to the WAF skip rule that already bypasses BIC/SBFM.
Reference score stabilised: A+ 140/100, 10/10 tests, confirmed across multiple successive scans of www.arleo.eu.
Final result
Mozilla Observatory : A+ 140/100 (10/10 tests)
CSP violations.log : 0 new (CF JS Detections disabled)
csp-monitor.sh : clean exit 0 every 15 min
Dark/light switch : 3 cycles tested with Puppeteer, 0 JS errorsThe complete Hugo → OpenResty → Cloudflare chain is now automatically monitored with rollback on regression, and Cloudflare credentials are encrypted at rest.