Ce qu'est le cross-site scripting
Le cross-site scripting (XSS) est une vulnérabilité où un attaquant fait inclure par une page de votre site du JavaScript qu'il a écrit. Le navigateur ne peut pas distinguer ce script du vôtre : il arrive depuis votre origine, donc il tourne avec tout ce que votre origine est autorisée à faire, y compris les sessions de vos utilisateurs.
La cause profonde est toujours la même. Un texte que quelqu'un d'autre contrôle, comme un commentaire, un terme de recherche, un nom d'affichage ou un fragment d'URL, est inséré dans une page d'une façon qui laisse le navigateur le lire comme balisage ou code plutôt que comme texte brut. Un commentaire qui devrait apparaître comme les caractères littéraux <script> devient à la place un élément script.
Le nom est historique et légèrement trompeur : l'attaque classique impliquait un second site, mais aujourd'hui la plupart du XSS, c'est simplement des données non fiables exécutées dans votre propre page. OWASP le classe dans la catégorie Injection de l'OWASP Top 10, juste à côté de son cousin côté serveur, l'injection SQL. La différence, c'est l'interpréteur. L'injection SQL trompe votre base de données ; le XSS trompe le navigateur de vos utilisateurs.
Le XSS est un bug de l'application, pas du navigateur ni du réseau. HTTPS ne l'empêche pas, un pare-feu ne l'empêche pas complètement, et seul le code qui construit la page peut le supprimer.
Les trois types : stocké, réfléchi et basé sur le DOM
Le XSS stocké (aussi appelé persistant) est le plus dommageable. L'entrée malveillante est sauvegardée par le serveur, dans un commentaire, un champ de profil, un avis produit ou un ticket de support, puis servie à chaque visiteur qui ouvre cette page, souvent y compris les administrateurs qui lisent le back-office. Personne n'a besoin de cliquer sur un lien spécial.
Le XSS réfléchi n'est pas stocké. Le serveur prend quelque chose de la requête, typiquement un paramètre de requête, et l'écrit directement dans la réponse : une page de recherche qui affiche « Résultats pour... », ou une page d'erreur qui répète la mauvaise valeur. L'attaquant doit amener la victime à ouvrir un lien fabriqué, par e-mail, chat ou un autre site.
Le XSS basé sur le DOM se produit entièrement dans le navigateur. Votre propre JavaScript lit une valeur contrôlée par l'attaquant, comme location.hash, location.search, des données postMessage ou document.referrer, et l'écrit dans un sink dangereux : innerHTML, document.write, eval, setTimeout avec une chaîne, ou une URL javascript: dans un href. Le serveur peut ne jamais voir la charge utile du tout, puisque tout ce qui suit # n'est pas envoyé avec la requête.
Les trois exemples pédagogiques ci-dessous montrent la forme de chaque bug. Dans chaque cas, la correction repose sur la même idée : traiter la valeur comme du texte.
Motifs vulnérables minimaux (ne les expédiez pas)
<!-- Stored: a saved comment printed without escaping (PHP) -->
<p><?= $comment->body ?></p>
<!-- body saved as: <script>alert(document.domain)</script> -->
<!-- Reflected: the search term echoed from the URL -->
<h1>Results for <?= $_GET['q'] ?></h1>
<!-- link: /search?q=<script>alert(document.domain)</script> -->
// DOM-based: client code writes the URL fragment as HTML
document.getElementById('greeting').innerHTML =
decodeURIComponent(location.hash.slice(1));
// link: /welcome#<img src=x onerror=alert(document.domain)>
Ce qu'un attaquant peut en faire
alert(1) est ce qu'utilisent les testeurs pour prouver le bug ; ce n'est pas ce que lancent les attaquants. Une fois leur script exécuté dans votre page, il agit comme l'utilisateur connecté, à l'intérieur de votre origine.
Vol de session. Si le cookie de session est lisible depuis JavaScript, le script l'envoie à l'attaquant, qui se connecte alors comme la victime. Les jetons conservés dans localStorage ou sessionStorage sont toujours lisibles par script, ce qui est le principal argument contre le stockage de jetons longue durée là-bas.
Actions au nom de l'utilisateur. Même quand le cookie est HttpOnly, le script peut appeler votre API avec fetch ; le navigateur attache lui-même le cookie. Il peut lire les jetons CSRF depuis la page, changer l'adresse e-mail du compte, créer un utilisateur admin, poster des messages ou passer des commandes. Les règles de même origine, CORS et les cookies SameSite n'arrêtent pas cela, parce que la requête vient de votre propre site.
Lire ce que voit l'utilisateur. Données personnelles, messages, factures, tout ce qui est sur la page ou accessible via votre API.
Phishing et keylogging sur votre domaine. Le script peut redessiner la page en formulaire de connexion ou enregistrer ce que l'utilisateur tape dans de vrais formulaires. La barre d'adresse affiche votre domaine et un certificat valide, donc l'utilisateur n'a aucune raison d'en douter.
Défacement et propagation. Le XSS stocké peut changer ce que chaque visiteur voit, ou se copier dans le profil de chaque victime. Le ver Samy de 2005 sur MySpace s'est propagé à plus d'un million de profils en moins d'une journée de cette façon.
La gravité d'un XSS donné dépend de qui voit la page. Un bug dans un écran réservé aux admins, atteint via une entrée stockée comme le sujet d'un ticket de support, est souvent pire qu'un bug sur la page d'accueil publique.
La correction : l'encodage de sortie sensible au contexte
La défense centrale consiste à encoder les données non fiables au moment où vous les écrivez dans la page, de la façon qu'exige le contexte environnant. Valider l'entrée aide (un code postal devrait ressembler à un code postal), mais cela ne peut pas être le contrôle principal, parce que la même valeur peut être sûre dans un contexte et dangereuse dans un autre.
Corps HTML : échappez &, <, >, " et ' en entités, pour que <script> soit affiché plutôt qu'analysé.
Attribut HTML : mettez toujours les valeurs d'attribut entre guillemets, et échappez les mêmes caractères. Un attribut non entre guillemets peut être cassé avec un simple espace.
URL dans href ou src : l'encodage ne suffit pas, parce que javascript:alert(1) ne contient rien à échapper. Analysez l'URL et n'autorisez que https: et http: (et mailto: si besoin) ; encodez les valeurs de requête avec encodeURIComponent.
À l'intérieur d'un bloc <script> : ne concaténez pas de chaînes dans du JavaScript. Sérialisez les données en JSON avec une fonction qui échappe aussi <, ou placez-les dans un attribut data- et lisez-les avec element.dataset.
Styles : évitez complètement de placer des données utilisateur en CSS.
Côté client : préférez les API sûres. textContent, setAttribute sur des attributs inoffensifs et createElement n'analysent jamais de HTML ; innerHTML, outerHTML, insertAdjacentHTML et document.write le font.
Écrire des données utilisateur en sécurité dans le navigateur
// Text, never markup
el.textContent = userName;
// Links: allow only http(s)
function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
link.href = safeUrl(profile.website);
// Data for scripts: a data attribute, not string concatenation
// <div id="app" data-user="{{ user_json }}"> (escaped by the template)
const user = JSON.parse(document.getElementById('app').dataset.user);
L'échappement automatique des frameworks, et ses issues de secours
Les frameworks modernes encodent déjà pour le contexte HTML par défaut. {{ }} dans Blade, Twig, Jinja, les templates Django, Vue et Angular, <%= %> dans Rails ERB et {value} dans React JSX échappent tous ce qu'ils affichent. C'est la raison principale pour laquelle le XSS est plus rare qu'il ne l'était dans les pages PHP écrites à la main, et la raison de continuer à utiliser la façon par défaut du framework d'afficher les valeurs.
Presque tout vrai XSS dans une base de code moderne vit dans une issue de secours, une fonctionnalité qui désactive délibérément l'échappement :
- React dangerouslySetInnerHTML, Vue v-html, Svelte {@html}, Angular bypassSecurityTrustHtml.
- Laravel Blade {!! $value !!}, Jinja |safe et Markup(), Django mark_safe et {% autoescape off %}, Rails raw et html_safe.
- Écritures DOM directes depuis le code de composant : ref.current.innerHTML = ..., jQuery .html(), $(userInput).
Les autres lacunes sont des contextes que l'auto-échappeur ne comprend pas. Un template qui échappe le HTML laisse encore passer javascript: dans href={url}, laisse encore une valeur utilisateur casser le code à l'intérieur d'un <script> inline ou d'un gestionnaire d'événement, et ne protège jamais une chaîne que vous passez à eval.
Une règle pratique : rendez les issues de secours rares, recherchables et relues. Une recherche de code pour la liste ci-dessus trouve la majeure partie de votre exposition, et une règle de linter (par exemple ESLint react/no-danger, ou une règle Semgrep pour {!!) empêche de nouvelles d'apparaître sans se faire remarquer.
Quand vous devez accepter du HTML : utilisez un vrai sanitizer
Certaines fonctionnalités ont besoin de HTML utilisateur : un éditeur de texte riche, un corps de CMS, un post de forum avec mise en forme, un aperçu d'e-mail. L'échappement détruirait la mise en forme, donc la réponse est le sanitizing : analyser le HTML et ne garder qu'une liste blanche d'éléments et d'attributs sûrs.
Utilisez une bibliothèque maintenue conçue pour ça, jamais une expression régulière ou une liste noire de balises « mauvaises ». Les navigateurs analysent le HTML de façons surprenantes, et chaque filtre écrit à la main qui retire <script> a été contourné avec un attribut gestionnaire d'événement, un élément SVG, une imbrication étrange ou une astuce d'encodage. Les choix établis sont DOMPurify dans le navigateur et dans Node.js avec jsdom, HTML Purifier pour PHP, nh3 (la bibliothèque Rust ammonia) pour Python, et l'OWASP Java HTML Sanitizer pour Java.
Trois détails font la différence. Sanitisez avec une liste blanche aussi petite que le nécessite la fonctionnalité. Sanitisez à la sortie, ou à nouveau à la sortie, pour qu'un changement ultérieur dans la bibliothèque ou dans votre liste blanche protège les anciennes données. Et ne modifiez pas le HTML après l'avoir sanitisé : une ré-analyse, un remplacement de chaîne ou une insertion dans un contexte différent peut transformer un résultat sûr en résultat dangereux.
Pour le Markdown, le HTML rendu a besoin du même traitement : la plupart des moteurs de rendu Markdown autorisent le HTML brut par défaut.
Limiter les dégâts : HttpOnly, SameSite et Content-Security-Policy
Supposez qu'un XSS finira par passer, et faites en sorte qu'il vaille moins cher.
Cookies. Marquez les cookies de session HttpOnly, pour que document.cookie ne puisse pas les lire, plus Secure et SameSite=Lax ou Strict. Cela arrête le scénario du cookie volé. Cela n'empêche pas le script d'agir comme l'utilisateur pendant que la page est ouverte, donc c'est une limitation des dégâts plutôt qu'une correction. Gardez les jetons longue durée hors de localStorage pour la même raison.
Content-Security-Policy. Une CSP dit au navigateur quels scripts peuvent s'exécuter. La politique qui arrête le XSS est une politique stricte : un nonce aléatoire généré à chaque réponse, placé à la fois dans l'en-tête et sur chaque balise <script> légitime, plus 'strict-dynamic', object-src 'none' et base-uri 'none'. Un <script> injecté n'a pas de nonce valide, et les gestionnaires d'événement inline comme onerror= sont bloqués, donc la plupart des bugs XSS deviennent une erreur console et un rapport. Une politique qui ne liste que des domaines et garde 'unsafe-inline' offre peu de protection contre le XSS.
Déployez-la d'abord en Content-Security-Policy-Report-Only, corrigez ce que montrent les rapports, puis appliquez-la. Le guide des en-têtes de sécurité HTTP couvre ce déploiement en mode rapport seul, les autres en-têtes qui vont à côté, et pourquoi X-XSS-Protection ne devrait plus être envoyé.
Un piège de cache : un nonce doit être imprévisible. Si un CDN ou un cache de page sert le même HTML à tout le monde, chaque visiteur reçoit le même nonce, et un attaquant peut le lire depuis la page. Pour les pages en cache, utilisez des hashes de script ('sha256-...') à la place, ou gardez hors du cache le HTML qui porte un nonce.
Là où les navigateurs le prennent en charge, require-trusted-types-for 'script' va plus loin : les sinks DOM comme innerHTML refusent les chaînes simples, donc le XSS basé sur le DOM doit passer par du code que vous avez écrit.
Une politique stricte à base de nonce (nouveau nonce à chaque réponse)
Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mPerResponse" src="/js/app.js"></script>
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Tester votre propre application pour le XSS
Ne testez que des systèmes que vous possédez ou pour lesquels vous êtes autorisé. Pour votre propre application, un marqueur inoffensif trouve la plupart des bugs sans aucun code d'attaque.
Tracez chaque entrée jusqu'à chaque sortie. Placez un marqueur unique contenant des caractères significatifs en HTML, par exemple xss7"'<b>bold</b>, dans chaque champ, paramètre de requête, en-tête que votre application affiche, et nom de fichier que vous acceptez. Visitez ensuite chaque page où cette valeur apparaît, y compris les écrans admin, les e-mails, les exports et les notifications. Si le mot apparaît en gras, ou si le code source de la page montre <b> non échappé ou un guillemet qui ferme un attribut, la sortie n'est pas encodée.
Vérifiez le DOM, pas seulement le code source. Pour les bugs basés sur le DOM, placez le marqueur dans location.hash et la chaîne de requête et inspectez le DOM en direct dans les outils de développement : view-source ne montre que ce que le serveur a envoyé.
Cherchez dans le code. Grep les issues de secours listées ci-dessus et innerHTML, insertAdjacentHTML, document.write, eval et new Function. Chaque résultat devrait soit disparaître, soit avoir un court commentaire expliquant pourquoi l'entrée est sûre.
Utilisez des outils. OWASP ZAP et Burp Suite parcourent et testent les entrées automatiquement et trouvent les cas réfléchis évidents ; l'analyse statique comme Semgrep ou CodeQL suit des flux de données qu'un crawler ne peut pas voir. Aucun ne remplace la trace manuelle pour le XSS stocké dans les écrans de back-office.
Surveillez les rapports CSP. Une fois une politique report-only en place, les violations venant de scripts inline inattendus sont une alerte précoce gratuite.
grep -rnE 'dangerouslySetInnerHTML|v-html|\{@html|bypassSecurityTrust|innerHTML|insertAdjacentHTML|document\.write' src/
grep -rnE '\{!!|\|safe|mark_safe|html_safe|raw\(' resources/ templates/ app/
Où se place un WAF, et ce que fait le WAF de CDN.com.tr
Un web application firewall inspecte les requêtes et bloque celles qui ressemblent à des attaques. Pour le XSS, c'est utile : les balises script et les gestionnaires d'événement dans une chaîne de requête ou un corps de formulaire sont reconnaissables, donc les tentatives réfléchies et les scanners automatisés sont arrêtés avant d'atteindre votre application, et une charge utile stockée envoyée via un formulaire normal est souvent refusée à l'entrée.
C'est une couche, pas la correction. Un WAF voit des requêtes, pas vos pages, donc il ne peut pas savoir comment une valeur sera utilisée plus tard. Le XSS basé sur le DOM qui vit dans le fragment d'URL ne l'atteint jamais, une entrée qui arrive via un import, une API que vous appelez ou un canal que le WAF n'inspecte pas lui échappe, et les attaquants ajustent leurs charges utiles pour éviter les motifs. Le guide OWASP Top 10 détaille ce qu'un pare-feu peut et ne peut pas attraper. Encodez la sortie, sanitisez le HTML et fixez une CSP, qu'un WAF soit devant ou non.
Sur CDN.com.tr, le WAF est ModSecurity avec l'OWASP Core Rule Set, s'exécutant à la périphérie et activé pour le compte depuis la page des règles de diffusion. La famille 941 du Core Rule Set est celle de ses règles de cross-site scripting. Un visiteur bloqué reçoit une page 403 personnalisée avec un identifiant de référence, et la page Journaux WAF liste les événements bloqués avec la catégorie d'attaque, le pays, l'IP et la règle, cherchable par cet identifiant sur les 30 derniers jours et exportable en CSV ou XLSX ; cdnctl waf logs renvoie la même liste depuis la ligne de commande.
Le faux positif habituel pour les règles XSS est un post HTML légitime, comme un éditeur qui enregistre du contenu formaté ou un exemple de code. Les exceptions pour des chemins individuels ne sont pas encore disponibles dans le panneau : si le WAF bloque une requête légitime, envoyez son identifiant de référence au support plutôt que de désactiver le WAF.
Des en-têtes peuvent aussi être ajoutés à la périphérie. Les en-têtes à valeur unique vont dans les En-têtes personnalisés d'une règle de diffusion, un par ligne, et s'appliquent aux réponses 2xx et 3xx, cache hits compris. Une Content-Security-Policy complète ne peut pas y aller, parce que le panneau et la périphérie refusent ; dans une ligne d'en-tête ; une seule directive comme frame-ancestors 'self' fonctionne, et un nonce doit de toute façon être généré par réponse, donc la politique appartient à votre application.
cdnctl waf logs --account $ACCOUNT_UUID --range 1d
cdnctl waf show $REFERENCE_ID --account $ACCOUNT_UUID
FAQ XSS
Quelle est la différence entre XSS stocké, réfléchi et basé sur le DOM ?
Là où vit la charge utile. Le XSS stocké est sauvegardé sur le serveur et servi à tous ceux qui ouvrent la page. Le XSS réfléchi revient de la même requête, donc la victime doit ouvrir un lien fabriqué. Le XSS basé sur le DOM est créé par votre propre JavaScript côté client qui écrit dans la page une valeur contrôlée par l'attaquant, souvent depuis l'URL ; le serveur peut ne jamais la voir.
HTTPS ou un certificat valide protège-t-il contre le XSS ?
Non. Le TLS protège les données en transit. Une charge utile XSS est livrée par votre propre serveur ou votre propre script sur la même connexion chiffrée, et le cadenas rend un faux formulaire de connexion sur votre domaine plus crédible, pas moins.
HttpOnly suffit-il à arrêter le XSS ?
Non. Cela empêche le script de lire le cookie de session, ce qui supprime un résultat possible. Le script peut encore envoyer des requêtes au nom de l'utilisateur, parce que le navigateur attache le cookie pour lui, et il peut encore lire et changer la page. HttpOnly est une limitation des dégâts ; l'encodage de sortie est la correction.
Un WAF peut-il arrêter le cross-site scripting ?
Il arrête de nombreuses tentatives réfléchies et automatisées, parce que les charges utiles de script dans les requêtes sont reconnaissables. Il ne peut pas voir le XSS basé sur le DOM dans le fragment d'URL, ne sait pas comment votre application utilisera une valeur stockée, et peut être évité par une charge utile sur mesure. Traitez-le comme une couche devant du code correct.
Devrais-je encore envoyer X-XSS-Protection ?
Non. Le filtre qu'il contrôlait a été retiré des navigateurs actuels, et dans les anciens il pouvait être détourné. N'envoyez rien ou X-XSS-Protection: 0, et utilisez une Content-Security-Policy à la place.
React ou Vue rendent-ils mon application immunisée contre le XSS ?
Ils rendent le cas courant sûr en échappant ce que vous affichez. Vous restez exposé via dangerouslySetInnerHTML et v-html, via des URL utilisateur dans href qui commencent par javascript:, via des écritures DOM directes, et via du HTML rendu côté serveur que le framework ne contrôle pas.
Qu'est-ce que le self-XSS ?
Un script que la victime est piégée à coller dans sa propre console de navigateur. Il n'a besoin d'aucun bug dans votre site, ce qui explique pourquoi les navigateurs avertissent quand vous collez dans les outils de développement. C'est de l'ingénierie sociale plutôt qu'une vulnérabilité dans votre code.