Définition
Le Cross-Site Scripting (XSS) est l’art, pour un attaquant, de faire exécuter son propre JavaScript dans le contexte d’un site légitime, par le navigateur de la victime.
C’est la vulnérabilité qui exploite le plus directement la brèche entre ce qu’un site croit afficher et ce que le navigateur exécute réellement. Une fois le script étranger admis dans le contexte du site, presque tout devient possible : lire ou modifier le DOM, voler des données côté client (formulaires, jetons stockés en localStorage), déclencher des requêtes authentifiées comme l’utilisateur, mener une campagne de phishing depuis un site légitime, voire installer une porte dérobée JavaScript persistante dans le cas d’un XSS stocké.
Typologie
XSS réfléchi
L’entrée utilisateur est directement reflétée par le serveur dans la réponse HTML, sans validation ni échappement. Une URL portant la charge utile en paramètre suffit :
https://site.com/?q=<script>alert(1)</script>
Si le serveur répond naïvement Vous avez recherché : <script>alert(1)</script>, le navigateur exécute le script — l’attaque n’a duré que le temps d’une seule requête.
XSS stocké
Définition
L’entrée est stockée (base de données, commentaire, profil) puis renvoyée à chaque affichage.
Plus dangereux que le réfléchi précisément parce qu’il ne dépend d’aucune interaction ponctuelle : chaque visiteur de la page contaminée exécute le script, sans qu’aucun lien piégé n’ait été nécessaire.
XSS basé sur le DOM
Définition
L’entrée est manipulée uniquement par le JavaScript côté client —
location.hash,location.search,localStorage— et injectée dans le DOM via un sink dangereux (innerHTML,document.write), sans jamais transiter par le serveur.
WARNING
Le DOM XSS casse entièrement les WAF traditionnels, car la requête HTTP elle-même est cent pour cent légitime. La corruption naît du code client, pas de la réponse HTTP, pas du serveur, pas d’un « hack réseau » au sens classique.
Je développe ce cas plus loin, car il mérite un traitement à part entière.
Self-XSS
Définition
L’attaquant convainc l’utilisateur de coller lui-même un code malveillant dans la console de son navigateur — sous prétexte de « débloquer un compte » ou « gagner un objet ».
Ce n’est pas, techniquement, une faille du site : c’est de l’ingénierie sociale qui détourne la console développeur. Elle mérite néanmoins d’être connue, car elle explique pourquoi tant de sites affichent désormais un avertissement dans leur console pour dissuader ce collage.
Première génération de défense : filtrer et bloquer
Le filtrage naïf
L’idée initiale consistait à interdire les caractères suspects à l’entrée. Cette approche s’est révélée intenable en pratique : trop stricte, elle casse des fonctionnalités légitimes ; trop permissive, elle laisse passer des charges utiles tordues ; et de toute façon, elle reste techniquement irréalisable avec un seul filtre par expression régulière face à la richesse du HTML.
Les anciens navigateurs ont un temps intégré X-XSS-Protection, un en-tête activant un filtre XSS embarqué. Son comportement, hélas, variait selon les navigateurs et restait parfois contournable — il est aujourd’hui largement abandonné au profit de la CSP et d’un véritable échappement applicatif.
Le WAF
Rôle
Inspecter le trafic HTTP et bloquer les requêtes ressemblant à des attaques — motifs de XSS, d’injection SQL, d’exécution de code distant.
WARNING
Un WAF ne corrige jamais la cause, et ne voit que ce qui transite effectivement dans les requêtes. Un DOM XSS pur, dont la charge utile réside dans
location.hash, peut ainsi passer totalement inaperçu.
Contourner filtres et WAF
Un WAF ou un filtre côté serveur fonctionne toujours sur des heuristiques : analyse de motifs, listes noires ou blanches de caractères, détection naïve de balises, inspection superficielle des encodages. Le problème fondamental est ailleurs :
WARNING
Un navigateur n’interprète pas le HTML comme un WAF. L’attaquant exploite précisément l’écart entre ce que le WAF croit comprendre et ce que le navigateur exécutera réellement — y compris un HTML malformé.
Casser un contexte pour en atteindre un autre
Chaque emplacement d’injection appelle une stratégie différente, résumée ici :
| Contexte initial | Ce que le payload doit casser | Pour atteindre… |
|---|---|---|
| Texte HTML | la structure HTML | une balise exécutable (<img onerror>, <svg>) |
| Attribut HTML | les guillemets | un gestionnaire d’événement (onerror=alert(1)) |
| JavaScript inline | la chaîne JS | du code JS pur (";alert(1);//) |
| URL | le parsing d’URL | du code via javascript: |
| CSS | le parsing CSS | pas de JS direct, mais des vecteurs indirects |
Une illustration concrète : si le serveur génère var x = "NOM_UTILISATEUR"; et qu’un attaquant soumet comme nom "; alert(1); var y=", le rendu final devient :
<script>
var x = "";
alert(1);
var y = "";
</script>Le navigateur exécute alert(1) — l’attaquant a cassé le contexte chaîne pour entrer dans le contexte code.
Un répertoire de stratégies
Plusieurs techniques s’enchaînent, souvent combinées : changer de vecteur HTML (n’importe quel gestionnaire d’événement déclenche du JS) ; jouer sur le contexte d’insertion d’un attribut ; encoder et obfusquer — en entités HTML (<script>), en encodage URL (%3Cscript%3E), voire en homoglyphes Unicode (script) — pour tromper un filtre qui ne reconnaît que la forme littérale ; construire des polyglottes, des charges utiles valides dans plusieurs parseurs à la fois (HTML, JS, CSS) ; et exploiter le parsing réel, parfois tolérant, du navigateur : <scri<script>pt>alert(1)</script> est reconstruit par Chrome en <script>alert(1)</script>, un WAF cherchant la chaîne littérale <script> n’y voyant, lui, que du bruit.
Défenses modernes : l’échappement contextuel
Définition
Transformer systématiquement l’entrée pour qu’elle reste toujours une donnée, jamais du code — quel que soit l’endroit où elle atterrit.
Pour un payload Robert\"); alert(1); /*, l’échappement transforme le rendu en une simple chaîne inoffensive : let name = 'Robert"); alert(1); /*';. Le principe se décline selon le contexte : l’échappement HTML remplace <, >, " et & par leurs entités ; l’échappement URL encode via encodeURIComponent ; chaque contexte (attribut, JS, CSS) a sa propre règle.
IMPORTANT
Les frameworks et moteurs de gabarits échappent par défaut tout ce qui est inséré comme texte. React échappe systématiquement le HTML injecté ; Angular combine sanitisation et gabarits stricts ; Vue échappe l’interpolation textuelle ; Twig, Jinja2 et les gabarits Django activent l’autoescape ; EJS et Handlebars échappent nativement.
Synthèse
La défense moderne contre le XSS consiste à échapper au moment de l’affichage, jamais en filtrant l’entrée en amont. Chaque contexte a son propre échappement, et les frameworks modernes le font automatiquement — un XSS survient presque toujours quand un développeur contourne cette protection par défaut (via
dangerouslySetInnerHTML,v-html, ou un équivalent).
Approfondir : le XSS basé sur le DOM
Le DOM XSS mérite un traitement séparé, car il inverse la logique de toutes les défenses réseau : le serveur n’est plus impliqué du tout. La vulnérabilité se loge uniquement dans le JavaScript exécuté par le navigateur, qui lit une source potentiellement contrôlée par l’attaquant et la réinjecte dans un sink dangereux.
Les sources dangereuses
Tout endroit d’où le JavaScript peut récupérer une donnée influençable par l’attaquant constitue une source : location.href, location.search, location.hash, document.URL, document.referrer, window.name, les messages reçus via postMessage, localStorage, sessionStorage, ou encore des données chargées depuis un fichier JSON ou une base IndexedDB. La surface est immense, car chacun de ces points peut être manipulé par un attaquant qui contrôle l’URL ou un message inter-fenêtres.
Les sinks dangereux
Un sink est une fonction où le navigateur interprète une chaîne comme du code ou du HTML actif. Les sinks HTML — innerHTML, outerHTML, insertAdjacentHTML, document.write — injectent directement du balisage interprété. Les sinks JavaScript — eval, Function, setTimeout/setInterval appelés avec une chaîne plutôt qu’une fonction — exécutent du code arbitraire. Les sinks indirects incluent l’attribution du src d’un <script>, ou d’un gestionnaire d’événement via setAttribute. Dès qu’un sink reçoit, sans validation, une donnée issue d’une source, la vulnérabilité existe.
Pourquoi les défenses classiques échouent ici
Un WAF HTTP ne voit que ce qui transite dans la requête réseau. Or le DOM XSS peut naître d’un fragment d’URL (#..., jamais transmis au serveur), du localStorage, d’un fichier JSON chargé côté client, ou d’un message reçu via postMessage depuis un autre domaine. Rien de tout cela n’est visible au niveau réseau — un WAF, aussi bien configuré soit-il, ne peut structurellement pas le bloquer.
Se défendre spécifiquement contre le DOM XSS
La règle la plus simple, et la plus efficace : ne jamais alimenter un sink HTML avec une donnée utilisateur. innerHTML et document.write sont à bannir dès qu’une donnée non fiable est en jeu, au profit de textContent, innerText, createElement et setAttribute, qui ne réinterprètent jamais leur contenu comme du balisage. Si du HTML riche doit réellement être autorisé (un éditeur de commentaires, par exemple), un sanitiseur robuste comme DOMPurify s’impose. Enfin, la seule discipline réellement fiable reste l’audit de la chaîne source → sink : suivre chaque entrée utilisateur jusqu’à son point de sortie effectif dans le DOM.
Un exemple réel illustre bien la sophistication que peut atteindre un tel payload, combinant fragment d’URL et encodage Unicode pour échapper à toute inspection réseau : une charge encodée en séquences \u{XX} reconstruit dynamiquement le mot script, extrait sa cible depuis location.hash.substr(1), et l’exécute via un sink dynamique — sans qu’aucun octet suspect ne transite jamais par une requête HTTP observable.