CSP A+ sur Hugo + Cloudflare : de hash-based à origin allowlist, monitoring auto et durcissement

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 :
# 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'
# → 1Un 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 :
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.
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 :
# /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) :
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.ageDécryptage à la volée dans le script :
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 :
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 :
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 :
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 :
{{- $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 JSLa 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.