Contents

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'
# → 1

A 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:

DirectiveValueReason
default-src'none'Block everything by default
script-src'self' cdn.jsdelivr.net challenges.cloudflare.com *.cloudflareinsights.comHugo Pipes (self) + Mermaid/libs CDN + CF Turnstile + analytics
script-src-attr'unsafe-hashes' 'unsafe-inline'LoveIt event handlers (onclick)
worker-src'self' cdn.jsdelivr.netMermaid v11 uses Web Workers
style-src'self' 'unsafe-inline' cdn.jsdelivr.netLoveIt inline styles
frame-ancestors'self'Anti-clickjacking
object-src'none'Block Flash/plugins
upgrade-insecure-requestsForce 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.

Diagram Diagram

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.age

On-the-fly decryption in the script:

cf_token=$(age -d -i "$AGE_KEY_FILE" "$CF_TOKEN_AGE")

Other findings

FindingFix
I1 lockfileflock -n 9 /run/lock/csp-monitor.lock
I2 Mermaid findRE too broadexact class="mermaid" → avoids mermaid-extra
I3 Mermaid SRI hardcodedresources.GetRemote $mermaidURL → hash computed at build
I4 awk|wc -l pipefail|| echo 0 on all pipelines
M1 known-patterns.txt unboundedFIFO cap at 1000 lines
M2 redundant canonical checkCF repair only triggered if directive missing
M3 inline script regex too broadExcludes type="module" and type="application/json"
M4 no heartbeatcurl $BETTERSTACK_HEARTBEAT at end of successful run
M5 mkdir -p no permissionschmod 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 selectorMechanismResult at first paint
[theme=dark] header { ... }Ancestor — matches as soon as html has the attributeCorrect immediately ✓
body[theme=dark] { background-color: #292a2d }Direct — requires the attribute on body itselfWhite 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 scannedCF-Cache-StatusScore
www.arleo.eu (direct)HIT140
arleo.eu → www.arleo.eu (redirect)BYPASS120

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 errors

The complete Hugo → OpenResty → Cloudflare chain is now automatically monitored with rollback on regression, and Cloudflare credentials are encrypted at rest.