---
title: "CSP A+ on Hugo + Cloudflare: from hash-based to origin allowlist, auto-monitoring and hardening"
description: "Full CSP hardening journey on arleo.eu: why hash-based failed with Cloudflare, migration to origin allowlist A+ 130/100, automated monitoring with csp-monitor.sh, and associated fixes (dark/light switch, javascript:void(0), Brooks-Lint C1-M5)."
url: "https://www.arleo.eu/en/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/"
language: "en"
datePublished: "2026-05-28T23:45:41+02:00"
dateModified: "2026-05-29T21:20:00+02:00"
tags: ["csp","nginx","hugo","cloudflare","security","openresty","monitoring","hardening","homelab","bash"]
categories: ["security","infrastructure","hardening","post-mortems"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# CSP A+ on Hugo + Cloudflare: from hash-based to origin allowlist, auto-monitoring and hardening
Full CSP hardening journey on arleo.eu: why hash-based failed with Cloudflare, migration to origin allowlist A+ 130/100, automated monitoring with csp-monitor.sh, and associated fixes (dark/light switch, javascript:void(0), Brooks-Lint C1-M5).



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

```bash
# 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:

```bash
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.

<!-- mermaid-source:Zmxvd2NoYXJ0IFRECiAgICBBWyJjc3AtbW9uaXRvci5zaDxici8+Y3JvbiAqLzE1Il0gLS0+IEJ7Im9wZW5yZXN0eSAtdCA/In0KICAgIEIgLS0gZmFpbCAtLT4gQ1siY3NwLXJlc3RvcmUuc2g8YnIvPnJvbGxiYWNrIC5iYWstKiJdCiAgICBDIC0tPiBEeyItdCBPSyBhZnRlciByb2xsYmFjayA/In0KICAgIEQgLS0geWVzIC0tPiBFW3N5c3RlbWN0bCByZWxvYWQgb3BlbnJlc3R5XQogICAgRCAtLSBubyAtLT4gRltBTEVSVCBtYW51YWxdCiAgICBCIC0tIG9rIC0tPiBHWyJjc3AtYmFja3VwLnNoPGJyLz52aG9zdCBzbmFwc2hvdCJdCiAgICBHIC0tPiBIeyJDU1AgZGlyZWN0aXZlczxici8+cHJlc2VudCA/In0KICAgIEggLS0gbWlzc2luZyAtLT4gSVtXQVJOICsgcHVyZ2UgQ0YgY2FjaGVdCiAgICBIIC0tIG9rIC0tPiBKeyJCdXJzdCB2aW9sYXRpb25zPGJyLz4+IDIwLzE1IG1pbiA/In0KICAgIEogLS0geWVzIC0tPiBLW0FMRVJUIGJ1cnN0XQogICAgSiAtLSBubyAtLT4gTHsiTmV3IHBhdHRlcm5zPGJyLz52aW9sYXRpb25zLmxvZyA/In0KICAgIEwgLS0geWVzIC0tPiBNWyJXQVJOICsgYXBwZW5kPGJyLz5rbm93bi1wYXR0ZXJucy50eHQiXQogICAgTCAtLSBubyAtLT4gTnsiSW5saW5lIHNjcmlwdHM8YnIvPmZyb20gb3JpZ2luID8ifQogICAgTiAtLSB5ZXMgLS0+IE9bV0FSTiBIdWdvIHJlZ3Jlc3Npb25dCiAgICBOIC0tIG5vIC0tPiBQeyJNRE4gT2JzZXJ2YXRvcnk8YnIvPmRlZ3JhZGVkIHNjb3JlIG9uY2UvZGF5ID8ifQogICAgUCAtLSBkZWdyYWRlZCAtLT4gUVtXQVJOIE1ETiBncmFkZV0KICAgIFAgLS0gb2sgLS0+IFJbImV4aXQgMDxici8+QmV0dGVyU3RhY2sgaGVhcnRiZWF0Il0= -->
{{< mermaid-svg hash="22a41d13" >}}

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

```bash
# /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):

```bash
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:
```bash
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:
```js
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`:

```js
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`:

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

```go
{{- $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 errors
```

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


## Tags

- csp
- nginx
- hugo
- cloudflare
- security
- openresty
- monitoring
- hardening
- homelab
- bash

## Categories

- security
- infrastructure
- hardening
- post-mortems
