Contenu

CSP Hash sur Hugo : migrer du nonce vers le hash pour préserver le cache CDN

Le problème : nonce CSP et cache CDN ne font pas bon ménage

Quand j’ai migré arleo.eu de Grav vers Hugo, j’ai tenté de porter l’architecture CSP nonce existante. Sur Grav (PHP), c’est élégant : le backend génère un nonce par requête, l’injecte dans les balises <script> via un plugin, et le transmet à nginx dans un header X-CSP-Nonce. nginx construit la CSP à la volée.

Le problème : cette approche est fondamentalement incompatible avec le cache CDN. Un nonce différent à chaque requête = un HTML différent à chaque requête = Cloudflare ne peut jamais servir depuis le cache. Le HTML doit être servi avec Cache-Control: no-store.

Sur un site statique Hugo, c’est absurde. Le contenu ne change pas entre les requêtes — seul le nonce change. La solution évidente est le hash CSP : calculer l’empreinte SHA-256 de chaque script inline au moment du build, l’inscrire une fois dans la CSP nginx, et ne plus y toucher jusqu’au prochain build.

L’architecture en place

Le site tourne sur un reverse proxy NUC → VM Hugo. nginx sur le NUC ne sert pas de fichiers statiques directement : il proxifie vers la VM (proxy_pass http://192.168.122.69:80). Cela signifie que le script de génération des hashes doit tourner sur la VM, là où public/ existe.

La migration nonce → hash impliquait trois changements :

Supprimer les sub_filter qui injectaient nonce="$csp_nonce" dans tous les <script et <style. Ces directives n’avaient pas d’effet sur le contenu entre les balises (les hashes restent donc valides), mais elles étaient inutiles et trompeuses.

Remplacer le snippet nonce (csp_nonce_report_only.conf) par un snippet hash généré automatiquement à chaque build.

Activer le cache HTML côté Cloudflare. Avec le nonce, le HTML partait en no-store. Avec les hashes, il peut être mis en cache s-maxage=86400 — Cloudflare sert le HTML pendant 24h sans toucher au NUC.

Le pipeline : csp-hash-gen.py

Un script Python stdlib (zéro dépendance externe) tourne sur la VM après chaque hugo build :

  1. Il parcourt les 393 fichiers HTML de public/
  2. Il extrait tous les <script> inline sans attribut src (et exclut les blocs application/ld+json)
  3. Il calcule le hash SHA-256 de chaque contenu, encodé en base64
  4. Il génère le snippet nginx avec la CSP complète

Le résultat est copié sur le NUC dans /etc/nginx/snippets/csp-hash-hugo.conf, puis nginx est rechargé.

Classification des 15 hashes

L’audit a produit 14 hashes script + 1 hash style, répartis en deux catégories :

Hashes stables (partagés par de nombreuses pages) : l’anti-flash dark mode présent sur 198 pages, les variantes de window.config selon le type de page (avec ou sans TypeIt, avec ou sans Mermaid, FR ou EN), et l’easter egg sur 2 pages.

Hashes Mermaid (1 par article avec diagramme) : chaque article embarque son diagramme complet dans window.config.data, ce qui produit un hash unique par article. Ces hashes sont stables à l’exécution — ils ne changent qu’à la modification du diagramme. Sur 6 articles concernés, cela donne 6 hashes fixes jusqu’au prochain edit.

La conséquence pratique : tout nouvel article avec un diagramme Mermaid ajoute un hash à la CSP. Le workflow deploy-hugo.sh régénère le snippet automatiquement — aucune intervention manuelle.

Validation avant enforce

Avant de passer en mode enforce, la CSP a tourné en Content-Security-Policy-Report-Only le temps de valider la couverture. Un script de vérification croisée a confirmé que les 15 hashes couvrent 100% des 393 fichiers HTML sans exception.

Hashes connus dans la CSP : 15
✅ Tous les blocs inline sont couverts par la CSP actuelle

Zéro violation. L’enforce a été activé immédiatement après.

Résultat

content-security-policy: default-src 'none';
  script-src 'self' 'sha256-bAT2QS…' 'sha256-ENBKJb…' [13 autres] ...;
  style-src 'self' 'sha256-mbsscG…';
  ...
cf-cache-status: HIT

Le HTML est maintenant mis en cache par Cloudflare. La CSP est enforce. Les rapports de violation arrivent sur l’endpoint /csp-report avec le body correctement capturé grâce à lua_need_request_body on.

Le workflow complet pour les prochains articles : hugo builddeploy-hugo.sh → c’est tout.