---
title: "CSP A+ sur Hugo + Cloudflare : de hash-based à origin allowlist, monitoring auto et durcissement"
description: "Parcours complet de durcissement CSP sur arleo.eu : pourquoi le hash-based a échoué avec Cloudflare, la migration vers origin allowlist A+ 130/100, le monitoring automatique csp-monitor.sh, et les correctifs associés (dark/light switch, javascript:void(0), Brooks-Lint C1-M5)."
url: "https://www.arleo.eu/posts/csp-a-plus-hugo-cloudflare-origin-allowlist/"
language: "fr"
datePublished: "2026-05-28T23:40:12+02:00"
dateModified: "2026-05-29T21:30:00+02:00"
tags: ["csp","nginx","hugo","cloudflare","security","openresty","monitoring","hardening","homelab","bash"]
categories: ["sécurité","infrastructure","hardening","Post-mortems"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# CSP A+ sur Hugo + Cloudflare : de hash-based à origin allowlist, monitoring auto et durcissement
Parcours complet de durcissement CSP sur arleo.eu : pourquoi le hash-based a échoué avec Cloudflare, la migration vers origin allowlist A+ 130/100, le monitoring automatique csp-monitor.sh, et les correctifs associés (dark/light switch, javascript:void(0), Brooks-Lint C1-M5).



## Contexte

arleo.eu tourne sur Hugo (VM KVM) → OpenResty (NUC) → Cloudflare (CDN/WAF). Objectif : score **A+ sur Mozilla Observatory** avec une CSP stricte qui résiste aux injections côté edge.

Le parcours a traversé trois stratégies en quelques semaines — nonces, hashes, puis origin allowlist — avant d'aboutir à une solution stable et automatisée.

---

## Acte 1 : pourquoi hash-based a échoué

L'idée de départ semblait solide : Hugo Pipes externalise tout le JS avec SRI, on liste les hashes dans la CSP, résultat propre. En pratique, deux problèmes ont rendu l'approche impossible.

### Cloudflare injecte après notre nginx

Le script Challenge Platform de Cloudflare (`__CF$cv$params`) est injecté **au edge**, après que le NUC a transmis la réponse. Il contient des valeurs dynamiques par requête (`r:`, `t:`) qui changent à chaque appel — impossible à hasher côté serveur.

La preuve empirique :

```bash
# Via NUC direct (bypass CF) → 0 script CF
curl -sk --resolve www.arleo.eu:443:127.0.0.1 https://www.arleo.eu/ | grep -c '__CF\$cv'
# → 0

# Via Cloudflare edge → 1 script injecté
curl -sk https://www.arleo.eu/ | grep -c '__CF\$cv'
# → 1
```

Un filtre Lua `body_filter_by_lua_block` avait même été tenté pour intercepter l'injection côté nginx — mais outre un bug de syntaxe (Lua patterns vs PCRE NGINX), l'approche était fondamentalement erronée : nginx ne voit que la réponse de l'origine, pas la réponse modifiée par le edge Cloudflare.

### Solution : désactiver CF JS Detections via API

Cloudflare ne documente pas bien l'endpoint, mais la communauté a trouvé la 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}'
```

Résultat confirmé : `enable_js: false`, `fight_mode: false`, plus d'injection.

---

## Acte 2 : la CSP origin allowlist

Sans injection CF, on peut construire une CSP **zero-inline** stricte. Chaque directive est justifiée :

| Directive | Valeur | Raison |
|-----------|--------|--------|
| `default-src` | `'none'` | Bloque tout par défaut |
| `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'` | Event handlers LoveIt (`onclick`) |
| `worker-src` | `'self' cdn.jsdelivr.net` | Mermaid v11 utilise Web Workers |
| `style-src` | `'self' 'unsafe-inline' cdn.jsdelivr.net` | Styles inline LoveIt |
| `frame-ancestors` | `'self'` | Anti-clickjacking |
| `object-src` | `'none'` | Bloque Flash/plugins |
| `upgrade-insecure-requests` | — | Force HTTPS |

**Score obtenu : A+ 130/100 sur Mozilla Observatory.** Le `worker-src` était la directive manquante qui bloquait le rendu Mermaid — Mermaid v11 lance ses parsers dans des Web Workers.

---

## Acte 3 : csp-monitor.sh — détection et réparation automatique

Un script cron `*/15 * * * *` surveille l'ensemble de la chaîne et répare automatiquement les régressions.

<!-- mermaid-source:Zmxvd2NoYXJ0IFRECiAgICBBWyJjc3AtbW9uaXRvci5zaDxici8+Y3JvbiAqLzE1Il0gLS0+IEJ7Im9wZW5yZXN0eSAtdCA/In0KICAgIEIgLS0gZmFpbCAtLT4gQ1siY3NwLXJlc3RvcmUuc2g8YnIvPnJvbGxiYWNrIC5iYWstKiJdCiAgICBDIC0tPiBEeyItdCBPSyBhcHLDqHMgcm9sbGJhY2sgPyJ9CiAgICBEIC0tIG91aSAtLT4gRVtzeXN0ZW1jdGwgcmVsb2FkIG9wZW5yZXN0eV0KICAgIEQgLS0gbm9uIC0tPiBGW0FMRVJUIG1hbnVlbF0KICAgIEIgLS0gb2sgLS0+IEdbImNzcC1iYWNrdXAuc2g8YnIvPnNuYXBzaG90IHZob3N0Il0KICAgIEcgLS0+IEh7IkRpcmVjdGl2ZXMgQ1NQPGJyLz5wcsOpc2VudGVzID8ifQogICAgSCAtLSBtYW5xdWFudGUgLS0+IElbV0FSTiArIHB1cmdlIENGIGNhY2hlXQogICAgSCAtLSBvayAtLT4gSnsiQnVyc3QgdmlvbGF0aW9uczxici8+PiAyMC8xNSBtaW4gPyJ9CiAgICBKIC0tIG91aSAtLT4gS1tBTEVSVCBidXJzdF0KICAgIEogLS0gbm9uIC0tPiBMeyJOb3V2ZWF1eCBwYXR0ZXJuczxici8+dmlvbGF0aW9ucy5sb2cgPyJ9CiAgICBMIC0tIG91aSAtLT4gTVsiV0FSTiArIGFwcGVuZDxici8+a25vd24tcGF0dGVybnMudHh0Il0KICAgIEwgLS0gbm9uIC0tPiBOeyJJbmxpbmUgc2NyaXB0czxici8+ZGVwdWlzIGwnb3JpZ2luZSA/In0KICAgIE4gLS0gb3VpIC0tPiBPW1dBUk4gcsOpZ3Jlc3Npb24gSHVnb10KICAgIE4gLS0gbm9uIC0tPiBQeyJNRE4gT2JzZXJ2YXRvcnk8YnIvPnNjb3JlIGTDqWdyYWTDqSAxeC9qID8ifQogICAgUCAtLSBkw6lncmFkw6kgLS0+IFFbV0FSTiBNRE4gZ3JhZGVdCiAgICBQIC0tIG9rIC0tPiBSWyJleGl0IDA8YnIvPmhlYXJ0YmVhdCBCZXR0ZXJTdGFjayJd -->
{{< mermaid-svg hash="fcefd9a7" >}}

### Rollback testé en conditions réelles

Le mécanisme de rollback a été validé : modification délibérée du vhost pour le rendre invalide → `openresty -t` échoue → le script restaure le dernier backup known-good → `systemctl reload openresty` → site de nouveau opérationnel. Diff vide entre avant et après le cycle.

---

## Acte 4 : durcissement Brooks-Lint

Une revue de code a identifié 11 findings. Tous appliqués :

### C1 — Wildcard dans sudoers (critique)

La règle initiale autorisait des globs dans les chemins `sudo cp` — vecteur d'attaque par symlink. Remplacé par deux wrappers root-owned sans paramètre :

```bash
# /usr/local/bin/csp-backup.sh — pas de wildcards exposés
cp "$VHOST" "$BACKUP_DIR/www.arleo.eu.bak-$(date +%Y%m%d_%H%M%S)"

# /usr/local/bin/csp-restore.sh — restaure uniquement le dernier .bak-*
latest=$(ls -t "$BACKUP_DIR"/www.arleo.eu.bak-* | head -1)
cp "$latest" "$VHOST"
```

Sudoers réduit à : `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 — Token CF en clair

Le token Cloudflare est chiffré avec `age` (disponible dans 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
```

Décryptage à la volée dans le script :
```bash
cf_token=$(age -d -i "$AGE_KEY_FILE" "$CF_TOKEN_AGE")
```

### Autres findings

| Finding | Fix |
|---------|-----|
| I1 lockfile | `flock -n 9 /run/lock/csp-monitor.lock` |
| I2 findRE Mermaid trop large | `class="mermaid"` exact → évite `mermaid-extra` |
| I3 SRI Mermaid hardcodé | `resources.GetRemote $mermaidURL` → hash calculé au build |
| I4 `awk\|wc -l` pipefail | `\|\| echo 0` sur toutes les pipelines |
| M1 known-patterns.txt illimité | FIFO cap 1000 lignes |
| M2 double check canonical | Repair CF déclenché uniquement si directive manquante |
| M3 regex inline scripts | Exclut `type="module"` et `type="application/json"` |
| M4 pas de heartbeat | `curl $BETTERSTACK_HEARTBEAT` en fin de run |
| M5 `mkdir -p` sans permissions | `chmod 700 $STATE_DIR` après création |

---

## Acte 5 : bugs associés découverts en chemin

### anti-flash.js — TypeError null body

Le script anti-flash est chargé en `<head>` sans `defer` (c'est le point : il doit s'exécuter avant le rendu pour éviter le flash de thème). Problème : `document.body` est `null` à ce stade.

Ancien code :
```js
document.body.setAttribute('theme', d ? 'dark' : 'light');
// → TypeError: Cannot read properties of null (reading 'setAttribute')
```

Fix : utiliser `document.documentElement` immédiatement, puis miroir sur `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` (la balise `<html>`) est toujours disponible, y compris pendant le parsing du `<head>`. Les sélecteurs comme `[theme=dark] header` utilisent un ancêtre — le header est correct immédiatement. En revanche, `body[theme=dark]` requiert l'attribut directement sur `body` : le fond blanc persiste jusqu'au `DOMContentLoaded`. Ce bug résiduel est corrigé à l'Acte 6.

### javascript:void(0) — violations CSP script-src-elem

Le thème LoveIt utilise `href="javascript:void(0);"` pour les boutons toggle (theme-switch, recherche, langue). Avec une CSP `script-src-elem` sans `'unsafe-inline'`, ces navigations vers une URL `javascript:` sont bloquées.

Fix : override de `layouts/_partials/header.html` avec `href="#" onclick="return false;"`. Les event handlers `onclick=` sont couverts par `script-src-attr 'unsafe-inline'`, et `return false` empêche le scroll vers `#`.

Hugo minifie automatiquement en `onclick=return!1` — valide.

---

---

## Acte 6 : corrections de deuxième passe (lendemain)

### Flash body — le fix de l'Acte 5 était incomplet

La correction anti-flash de l'Acte 5 couvrait le header mais pas le fond du body. Deux sélecteurs CSS coexistent dans LoveIt avec des comportements différents :

| Sélecteur CSS | Mécanisme | Résultat au premier paint |
|---------------|-----------|---------------------------|
| `[theme=dark] header { ... }` | Ancêtre — matche dès que `html` a l'attribut | Correct immédiatement ✓ |
| `body[theme=dark] { background-color: #292a2d }` | Direct — requiert l'attribut sur `body` | Fond blanc jusqu'à `DOMContentLoaded` ✗ |

Symptôme visible : header sombre correct, body fond blanc pendant ~200 ms à chaque navigation.

Fix : ajouter dans `assets/css/_custom.scss` un sélecteur ancêtre pour le body :

```scss
html[theme=dark] body {
  background-color: #292a2d;
  color: #a9a9b3;
}
```

Spécificité `(0,1,2)` > `body {}` `(0,0,1)` → s'applique dès le premier paint, avant tout JavaScript. `body[theme=dark]` prend ensuite le relais après `DOMContentLoaded` avec les mêmes valeurs, sans régression.

### Mermaid SRI — integrity vide sans Fingerprint

`resources.GetRemote` seul ne calcule pas le hash SRI — `.Data.Integrity` reste vide, ce qui donne `integrity=""` dans le HTML.

Fix : piper par `resources.Fingerprint "sha256"` dans `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` télécharge le fichier ; `resources.Fingerprint` calcule le hash. Les deux étapes sont requises.

### Score Observatory — pourquoi ça fluctue

Deux URL scannées, deux résultats :

| URL scannée | CF-Cache-Status | Score |
|-------------|-----------------|-------|
| `www.arleo.eu` (direct) | HIT | **140** ✓ |
| `arleo.eu → www.arleo.eu` (redirect) | BYPASS | **120** ✗ |

Quand Observatory démarre sur `arleo.eu` (sans www), CF sert parfois une réponse BYPASS (non-cachée) avec des headers issus d'une entrée stale. Ce n'est pas le score de référence — toujours scanner `www.arleo.eu` directement.

Cause additionnelle initiale : CF **Bot Fight Mode** interceptait le scanner Observatory et lui servait une challenge page avec sa propre CSP minimale (`default-src 'none'; style-src 'unsafe-inline'; script-src https://challenges.cloudflare.com ...`) — ce qui expliquait les scans à 120 avant notre intervention. Fix : ajouter `or (http.user_agent contains "http-observatory")` à la règle WAF skip qui bypasse déjà BIC/SBFM.

**Score de référence stabilisé : A+ 140/100, 10/10 tests**, confirmé sur plusieurs scans successifs de `www.arleo.eu`.


## Résultat final

```
Mozilla Observatory : A+ 140/100 (10/10 tests)
CSP violations.log  : 0 nouvelles (CF JS Detections désactivé)
csp-monitor.sh      : exit 0 propre toutes les 15 min
Dark/light switch   : 3 cycles testés Puppeteer, 0 erreur JS
```

La chaîne complète Hugo → OpenResty → Cloudflare est maintenant surveillée automatiquement avec rollback en cas de régression, et les credentials Cloudflare sont chiffrés au repos.


## Tags

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

## Categories

- sécurité
- infrastructure
- hardening
- Post-mortems
