Contenu

De 46 hashes à zéro : migration CSP vers les nonces dynamiques

⚡ En bref

La CSP initiale listait 46 hashes SHA-256 pour couvrir les scripts et styles inline — ingérable, fragile, et à la limite des 4 096 caractères de Cloudflare. Cette migration vers des nonces dynamiques réduit la CSP à ~600 caractères, supprime toute maintenance manuelle des hashes, et ajoute un reporting structuré des violations dans BetterStack.

Le plugin Grav utilisé est disponible sur GitHub : 🔌 Plugin : jmrGrav/grav-plugin-csp-nonce

🧠 Pourquoi

Quand on implémente une Content Security Policy stricte sur un site Grav CMS servi via Cloudflare, l’approche naïve consiste à lister les hashes SHA-256 de chaque script inline. C’est fonctionnel, mais ça devient vite ingérable. Plusieurs problèmes cumulés :

  • Limite Cloudflare : les Transform Rules ont une limite de 4 096 caractères. Avec 46 hashes, on frôlait le plafond.
  • strict-dynamic mal compris : avec strict-dynamic, les domaines dans script-src sont ignorés par les navigateurs CSP Level 3.
  • Fragile : un espace ou un attribut supplémentaire dans un tag <script> invalide le hash.
  • Pas de reporting fonctionnel : Reporting-Endpoints était absent, les violations ne remontaient pas.

🔧 Ce qui a été fait

Architecture finale

Visiteur
  → Cloudflare (cache CSS/JS/images, bypass HTML — no-store)
  → nginx (lit X-CSP-Nonce depuis FastCGI, compose le header CSP)
  → PHP-FPM / Grav (génère le nonce, l'injecte dans <script> et <style>)
  → Violations CSP → /csp-report → nginx log JSON → Vector → BetterStack

Plugin Grav : csp-nonce

Un plugin minimal abonné à onOutputGenerated :

public function onPluginsInitialized(): void
{
    if ($this->isAdmin()) return;
    $this->nonce = base64_encode(random_bytes(16));
    $this->enable([
        'onTwigVariables'    => ['onTwigVariables', 0],
        'onOutputGenerated'  => ['onOutputGenerated', 0],
    ]);
}

public function onOutputGenerated(): void
{
    $nonce = $this->nonce;
    $output = preg_replace_callback('/<script(\s[^>]*)?(>)/i', function($m) use ($nonce) {
        if (strpos($m[1] ?? '', 'nonce=') !== false) return $m[0];
        return '<script' . ($m[1] ?? '') . ' nonce="' . $nonce . '">';
    }, $this->grav->output);
    $this->grav->output = $output;
    if (!headers_sent()) header('X-CSP-Nonce: ' . $nonce);
}

Le nonce est généré avec random_bytes(16) — 128 bits d’entropie, impossible à deviner.

nginx : composition du header CSP

set $csp_nonce $upstream_http_x_csp_nonce;
fastcgi_hide_header X-CSP-Nonce;

add_header Content-Security-Policy "
  default-src 'none';
  script-src 'self' 'nonce-$csp_nonce' 'report-sample';
  style-src 'self' 'nonce-$csp_nonce' 'report-sample';
  font-src 'self';
  img-src 'self' www.gravatar.com data:;
  object-src 'none';
  upgrade-insecure-requests;
  report-to csp-endpoint;
" always;

Cloudflare : bypass du cache HTML

# Dans le bloc location ~ \.php$
add_header Cache-Control "no-store" always;

Élimination de unsafe-hashes

  • footer.html.twig : style="display: flex; ..." → classe CSS .footer-content
  • hero.html.twig : style="background-image: url(...)" → attribut data-hero-bg lu par JS
  • cookie-banner.js : <style> dynamique → CSS déplacé dans custom.css

Bilan

Critère Avant Après
Hashes scripts 46 0
Hashes styles 5 0
unsafe-hashes présent supprimé
Taille CSP ~4 000 chars ~600 chars
Maintenance manuelle automatique
Reporting non fonctionnel BetterStack structuré
Cache HTML Cloudflare actif bypass (no-store)

🏁 Conclusion

La migration vers les nonces dynamiques simplifie radicalement la maintenance de la CSP tout en renforçant sa sécurité. Chaque visiteur reçoit un nonce unique par requête — impossible à prévoir et à réutiliser.

Pour aller plus loin :

  • 💡 Ajouter une alerte BetterStack sur les violations CSP répétées pour détecter une tentative d’injection XSS réelle
  • 💡 Implémenter le mode Content-Security-Policy-Report-Only en parallèle pour tester les futures modifications CSP sans impacter les utilisateurs