Contenu

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

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.

Diagram Diagram

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

Dé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 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.