🍪🛡️ La CSP en Rails : protégez vos applications contre le XSS
Hello les petits Biscuits !
Bienvenue sur la 46ème édition de Ruby Biscuit.
Vous êtes maintenant 609 abonnés 🥳
Bonne lecture.
Vous lisez Ruby Biscuit, la newsletter Rails de Capsens. S’abonner gratuitement
En sécurité, aucune barrière unique ne tient indéfiniment. L’échappement automatique de Rails arrête l’essentiel des attaques XSS, mais le jour où un html_safe mal placé le contourne, il faut une deuxième ligne. La Content Security Policy est cette ligne : elle décide, côté navigateur, quels scripts ont le droit de s’exécuter.
Aujourd’hui, nous allons voir :
pourquoi l’échappement des sorties ne suffit pas à fermer la porte au XSS
ce qu’est une CSP et comment elle décide quel script a le droit de s’exécuter
comment la mettre en place dans Rails sans rouvrir la porte qu’on vient de fermer
1. Le problème : les injections de script malveillantes
La menace de fond, c’est le Cross-Site Scripting (XSS). L’idée tient en une phrase : un attaquant glisse du JavaScript dans une page que votre utilisateur consulte, et ce code s’exécute avec exactement les mêmes droits que le vôtre.
Il en existe trois saveurs :
Le XSS stocké. Le script est enregistré côté serveur (un commentaire, un champ de profil) et rejoué à chaque affichage. Tout le monde y passe.
Le XSS réfléchi. Le script voyage dans un paramètre d’URL et revient dans la réponse sans détour. Un lien piégé suffit.
Le XSS via le DOM. Cette fois le serveur est hors de cause : c’est votre JavaScript côté client qui prend une donnée non fiable et la réinjecte dans la page.
Et une fois le script en place, l’addition est salée. Vol de cookies de session, usurpation d’identité, exfiltration des données affichées, actions déclenchées au nom de la victime, redirection vers du phishing. Sur une interface d’admin, où les comptes ont les pleins pouvoirs, c’est encore plus direct. Le plus déroutant ? Tout ça peut se produire sans la moindre erreur visible : la page s’affiche normalement, rien ne clignote en rouge. Que s’est-il passé ? Un script qui n’aurait jamais dû tourner a simplement fait son travail.
L’échappement des sorties, que Rails fait par défaut, reste votre première barrière. Mais une sécurité qui repose sur un seul point de contrôle finit toujours par céder le jour où ce point lâche. D’où l’idée de défense en profondeur : ajouter une couche qui prend le relais. La CSP, c’est cette couche.
2. La réponse : la Content Security Policy
Le principe est limpide. Le serveur ajoute un en-tête HTTP, Content-Security-Policy, qui dit au navigateur : voici les seules sources auxquelles tu as le droit de faire confiance pour les scripts, les styles, les images, et le reste. Tout ce qui n’y figure pas est refusé.
Résultat : même si un attaquant réussit son injection, le navigateur bloque l’exécution. La faille existe toujours mais ne mène nulle part.
Une politique s’écrit avec des directives. Les principales :
default-src: la valeur par défaut quand rien de plus précis n’est défini.script-src: les sources autorisées pour le JavaScript. C’est elle qui fait le gros du travail contre le XSS.style-src,img-src,connect-src,frame-src: leurs cousines pour les styles, images, requêtes réseau et iframes.
La valeur 'self' autorise ce qui vient de votre propre domaine. C’est un excellent point de départ.
Le piège des scripts inline
Voilà où ça se complique. script-src 'self' autorise vos fichiers JS mais bloque tous les scripts inline, ceux écrits directement entre des balises <script> dans le HTML. Or certains engines et gems en génèrent pour leurs comportements dynamiques, ActiveAdmin par exemple avec ses formulaires.
La tentation, à ce stade, c’est d’ajouter 'unsafe-inline'. Surtout pas. Cette valeur réautorise tous les scripts inline, y compris celui que l’attaquant vient d’injecter. Vous venez de désactiver la protection que vous mettiez en place.
Il existe deux façons propres d’autoriser vos scripts inline légitimes :
Les hash. On calcule l’empreinte du script et on l’ajoute à la politique. Pratique pour quelques scripts statiques.
Les nonces. Un nonce, pour number used once, c’est un jeton à usage unique. À chaque requête, le serveur en génère un, aléatoire, l’inscrit dans l’en-tête et l’attache à chaque script légitime. Le navigateur n’exécute que les scripts qui portent ce jeton. Comme il change à chaque chargement et reste imprévisible, l’attaquant ne peut pas le deviner.
Pour une appli Rails qui rend du contenu dynamique et plusieurs scripts par page, le nonce gagne haut la main : sécurité solide, code lisible, et on dort tranquille. C’est cette approche qu’on déroule maintenant.
3. L’implémentation
Rails gère la CSP nativement depuis sa version 5.2, via un initializer dédié. Le plan : poser une politique stricte, et faire en sorte que vos scripts inline légitimes continuent de tourner grâce aux nonces, y compris ceux générés par des engines comme ActiveAdmin.
Étape 1 : la politique et le générateur de nonce
Tout se passe dans config/initializers/content_security_policy.rb. On y déclare la politique, puis on configure la génération du nonce :
Chaque réponse embarque désormais un en-tête script-src 'self' 'nonce-...', dont le suffixe change à chaque page.
Note : la configuration du nonce suit l’approche recommandée par le guide de sécurité de Rails. On a en revanche resserré les directives volontairement : le guide donne :self, :https en exemple, ce qui autorise n’importe quelle source HTTPS, alors qu’on s’en tient ici à :self pour une politique réellement stricte. Côté nonce, SecureRandom.base64(16) génère une valeur différente à chaque requête, ce qui est le bon défaut mais incompatible avec le cache conditionnel GET : un nouveau nonce produit un nouvel ETag à chaque appel. Si vous comptez sur ce type de cache, le guide propose d’utiliser l’identifiant de session à la place.
Étape 2 : commencer en mode observation
Avant de bloquer quoi que ce soit en prod, on déploie la politique sans qu’elle empêche rien. Le navigateur signale ce qu’il aurait bloqué mais laisse tout passer. On cartographie ainsi les violations sans casser l’expérience.
Quand les rapports sont propres, on repasse à false. Couplé à une directive report-uri ou report-to, ça transforme le déploiement en collecte de données plutôt qu’en série de bugs à corriger dans l’urgence. Un petit effort en amont qui évite une mauvaise surprise un vendredi soir.
Ensuite, pour visualiser les rapports, deux possibilités :
Tester en local : sans configuration supplémentaire, les violations s’affichent simplement dans la console du navigateur (onglet Console des DevTools), avec un message « would have been blocked ». Pratique pour un test manuel, mais ça ne remonte que ce qui se passe sur votre machine.
Tester en production : ajoutez une directive
report-uri(oureport-to) pointant vers une URL. Le navigateur y enverra automatiquement un rapport JSON à chaque violation, que vous pouvez logger via une route Rails maison ou brancher sur un service dédié (Report URI, Sentry, Datadog) pour un vrai dashboard.
Étape 3 : exposer le nonce aux vues d’un engine
Certains engines produisent leurs scripts inline hors de vos vues, ActiveAdmin par exemple. Pour qu’ils portent le bon nonce, il faut le rendre accessible dans leur contexte. On l’expose via une méthode d’aide au niveau du contrôleur :
content_security_policy_nonce renvoie le nonce de la requête en cours. En l’exposant comme helper_method, on peut l’appeler directement dans les blocs de définition des ressources, là où les formulaires sont décrits.
Étape 4 : attacher le nonce aux scripts inline
Partout où un script inline est nécessaire, souvent dans un bloc form do … end, on lui passe l’attribut nonce. Ici, par exemple, on affiche un champ « motif » uniquement lorsqu’une case « compte à risque » est cochée :
Ce script ne s’exécute que s’il présente le jeton attendu pour la requête. Tout script injecté par un tiers, qui ne le porte pas, est rejeté par le navigateur.
Étape 5 : faire fonctionner Google Tag Manager
Les scripts maison, c’est réglé. Reste le cas qui pose le plus de questions : les outils tiers comme Google Tag Manager.
GTM fonctionne en deux temps. Il charge d’abord son script de conteneur, puis ce conteneur injecte dynamiquement, au runtime, les balises que vous avez configurées dans son interface.
Problème : ces balises injectées après coup ne portent pas le nonce généré côté serveur, puisque le serveur ne les a jamais vues passer. Autoriser le seul domaine de GTM dans script-src ne suffit donc pas, les balises qu’il charge ensuite restent bloquées.
La réponse tient en deux ingrédients : poser le nonce sur le snippet GTM, et ajouter le mot-clé strict-dynamic à la politique.
D’abord, on rend le snippet GTM dans le layout avec un nonce, exactement comme un script inline classique :
Ensuite, on ajoute strict-dynamic à script-src, et on déclare les directives dont les tags ont besoin pour communiquer :
C’est strict-dynamic qui fait la magie : il étend la confiance accordée au snippet GTM (validé par son nonce) à tous les scripts que ce snippet charge à son tour. Plus besoin de lister un à un les domaines de chaque balise dans script-src, ce qui permet justement de les en retirer.
Note : strict-dynamic règle le chargement des scripts, pas les requêtes réseau. Les beacons de conversion GA4 ou Google Ads sont des appels réseau, gouvernés par connect-src : il faut donc les autoriser séparément, sinon les tags se chargent mais le tracking ne remonte pas. Les domaines exacts dépendent des balises actives ; le mode report-only de l’étape 2 est le bon moyen de les repérer avant de passer en enforce.
Note : sur les navigateurs qui comprennent strict-dynamic (en gros tout ce qui date d’après 2022), la liste de domaines de script-src est ignorée au profit de la propagation par nonce. Les navigateurs plus anciens, eux, ignorent strict-dynamic et retombent sur cette liste : si vous en avez retiré les domaines GTM, ils n’exécuteront pas les tags. C’est un arbitrage assumé, GTM cesse de fonctionner sur une frange résiduelle de navigateurs en échange d’une politique nettement plus simple et plus stricte.
La contrepartie de fond, à garder en tête : strict-dynamic accorde une confiance qui se propage. Tout ce que GTM charge devient de confiance par ricochet. Mieux vaut donc garder la main sur qui peut ajouter des balises dans le conteneur.
Étape 6 : redémarrer le serveur
Les changements d’initializer ne sont pris en compte qu’au démarrage de l’application. Un simple redémarrage suffit donc pour activer la nouvelle politique :
Étape 7 : vérifier
Quatre points à contrôler :
L’en-tête contient bien
Content-Security-Policy: script-src 'self' 'nonce-...'.Les scripts inline portent un attribut
nonce="..."qui correspond exactement.Vos scripts inline, y compris ceux des engines, s’exécutent normalement.
La politique reste stricte, sans retour à
'unsafe-inline'.
Et voilà : politique stricte d’un côté, scripts légitimes qui tournent de l’autre.
Note : si un champ dynamique cesse de répondre sans erreur JavaScript visible, allez voir la console. Un message du type « Refused to execute inline script because it violates the following Content Security Policy directive » trahit aussitôt une violation de CSP. C’est souvent là que se cache le coupable.
En deux mots
La CSP ne remplace pas l’échappement ni les bonnes pratiques : elle les complète. Sa force, c’est de déplacer le contrôle au niveau du navigateur, là où une injection résiduelle aurait pu s’exécuter tranquillement. Avec Rails, l’approche par nonce concilie une politique stricte et les scripts inline dont votre application a besoin, y compris ceux d’engines comme ActiveAdmin.
Mais cette rigueur a un prix. Une politique stricte recale tout ce qui ne vient pas de votre domaine et ne porte pas le bon jeton, à commencer par les scripts tiers. Avec strict-dynamic, une balise ajoutée dans GTM se charge sans redéploiement, mais dès qu’elle doit joindre un nouveau domaine, beacon ou iframe, il faut l’ouvrir dans connect-src ou frame-src, donc repasser par les devs. On gagne en sécurité une partie de ce qu’on perd en libre-service.
À vous de placer le curseur. Dès lors qu’une exposition au XSS coûte plus cher que quelques allers-retours avec les devs, et c’est souvent le cas, la politique stricte reste le bon défaut. La sécurité gagne à être pensée comme une partie du produit, pas comme une couche posée à la va-vite par-dessus. Et voilà, vous savez maintenant à quoi vous engager.
— Inès, Responsable de la Sécurité des Systèmes d'Information chez Capsens







