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-dynamicmal compris : avecstrict-dynamic, les domaines dansscript-srcsont 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 → BetterStackPlugin 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-contenthero.html.twig:style="background-image: url(...)"→ attributdata-hero-bglu par JScookie-banner.js:<style>dynamique → CSS déplacé danscustom.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-Onlyen parallèle pour tester les futures modifications CSP sans impacter les utilisateurs