Le Cross-Site Scripting (XSS)
Injecter du JavaScript dans la page d'une victime : comment fonctionne le XSS (stocké, réfléchi, DOM) — et comment l'éliminer.
Le cross-site scripting (XSS) est l'une des vulnérabilités les plus répandues du web, et elle est apparue en réponse directe à la quantité croissante d'interactions utilisateur dans les applications modernes. Son principe est d'une simplicité redoutable : un attaquant parvient à faire exécuter du JavaScript arbitraire dans le navigateur d'un autre utilisateur, en abusant de la confiance que ce navigateur accorde au site visité. Le code malveillant s'exécute alors avec les mêmes droits que le code légitime de l'application — il voit le DOM, lit les cookies, et peut émettre des requêtes au nom de la victime.
Ce chapitre éducatif et défensif suit la double lecture du livre d'Andrew Hoffman : d'abord comprendre conceptuellement le mécanisme des grandes familles de XSS (stocké, réfléchi, basé sur le DOM, par mutation), puis consacrer l'essentiel du propos aux contre-mesures. L'objectif n'est jamais d'attaquer un système tiers — ce qui serait illégal — mais de savoir construire un code qui ne se laisse pas piéger. Rappel de cadre : on ne teste que des systèmes que l'on possède ou pour lesquels on dispose d'une autorisation écrite, et toute faille découverte relève de la divulgation responsable.
Pourquoi le XSS est critique
Au cœur de l'attaque, on trouve un fait incontournable : les applications web exécutent des scripts dans le navigateur de leurs utilisateurs. Tout script créé dynamiquement met l'application en danger dès lors que son contenu peut être contaminé ou modifié par un tiers — en particulier par un utilisateur final.
Une fois qu'un script étranger s'exécute dans la page, il dispose de capacités étendues. D'après Hoffman, un XSS peut :
- Exécuter un script que le propriétaire du site n'a pas écrit.
- Tourner en arrière-plan, sans visibilité ni action requise de la victime.
- Lire n'importe quelle donnée présente dans l'application courante.
- Envoyer et recevoir librement des données vers un serveur malveillant.
- Voler des jetons de session (session tokens), menant à la prise de contrôle de compte.
- Redessiner des objets du DOM par-dessus l'interface, pour un hameçonnage (phishing) parfait, indétectable par un utilisateur non technique.
Piège courant
La cause racine est toujours la même : des données non fiables interprétées comme du code ou du markup au lieu d'être traitées comme du texte. Tout l'effort défensif consiste à maintenir cette frontière entre « données » et « code ».
Comment ça marche : les grandes familles
Le livre, comme l'OWASP, distingue trois grandes catégories de XSS, auxquelles s'ajoute une variante plus récente. Voici leur mécanisme, présenté de façon conceptuelle.
XSS stocké (stored)
Le XSS stocké (stored XSS) est probablement la forme la plus courante. La charge utile (payload) est enregistrée côté serveur — typiquement dans une base de données — puis renvoyée plus tard à tous les visiteurs qui consultent la ressource concernée.
Hoffman illustre cela avec un portail de support : un client mécontent rédige un commentaire et, voulant mettre des mots en gras, y glisse une balise <strong>. Si l'application réinjecte le commentaire tel quel dans la page, la balise est interprétée comme du markup et le texte s'affiche en gras. Le développeur a, sans le savoir, transformé un champ de texte en un canal d'injection de DOM. L'enchaînement fatal est le suivant :
l'utilisateur soumet un commentaire ->
le commentaire est stocké en base ->
le commentaire est demandé via HTTP par d'autres utilisateurs ->
le commentaire est injecté dans la page ->
il est interprété comme du DOM plutôt que comme du texte Le défaut tient souvent à une seule ligne qui applique littéralement le contenu reçu au DOM :
// VULNÉRABLE : le commentaire est interprété comme du DOM.
const comment = "mon <strong>commentaire</strong>";
const div = document.createElement("div");
div.innerHTML = comment; // les balises deviennent du markup
const wrapper = document.querySelector("#commentArea");
wrapper.appendChild(div); Si la balise inoffensive <strong> passe, une balise <script> passe aussi. Le script s'exécute alors dès qu'il atteint le DOM, sans aucune interaction : il peut parcourir la page avec document.querySelector(), collecter des données privilégiées (un agent de support voit des informations personnelles), puis les exfiltrer vers un serveur tiers. Et comme il est stocké, chaque utilisateur privilégié qui ouvre ce commentaire est attaqué à son tour.
Point notable : la nature permanente du XSS stocké le rend facile à détecter. Le script repose en clair côté serveur ; un balayage régulier des entrées de la base peut repérer des traces de script. Mais ce n'est pas une solution finale, prévient Hoffman : un payload avancé peut être encodé (base64, binaire), ou éclaté en plusieurs morceaux assemblés seulement au moment de l'usage côté client.
XSS réfléchi (reflected)
Le XSS réfléchi (reflected XSS) fonctionne comme le stocké, mais la charge n'est pas enregistrée : elle provient de la requête de la victime et est renvoyée non échappée par le serveur dans la réponse.
L'exemple du livre est une barre de recherche. Une recherche redirige vers support.mega-bank.com/search?query=open+savings+account, et la page affiche « 3 résultats pour "open savings account" ». Le titre reflète le paramètre d'URL. Si ce paramètre est réinjecté sans échappement, un contenu actif placé dans l'URL se retrouve exécuté dans la page de la victime.
Le réfléchi est plus difficile à détecter que le stocké, car rien n'est conservé en base : l'attaque vise une personne précise, via un lien piégé distribué par e-mail ou publicité. En revanche, il est généralement plus difficile à diffuser largement : il faut amener la victime à ouvrir le lien ou à effectuer une action.
XSS basé sur le DOM (DOM-based)
Le XSS basé sur le DOM (DOM-based XSS) se distingue radicalement : il ne nécessite aucune interaction avec un serveur. C'est du code JavaScript côté client qui lit une donnée non fiable et l'insère dans le DOM de façon dangereuse. On tend même à le classer dans une catégorie « XSS côté client ».
Deux ingrédients sont requis dans le navigateur : une source (source) — un objet du DOM capable de stocker du texte non fiable — et un puits (sink) — une API du DOM capable d'exécuter ce texte comme un script.
// VULNÉRABLE : source = window.location.hash, sink = document.write.
const hash = document.location.hash;
const funds = [];
const nMatches = findNumberOfMatches(funds, hash);
document.write(nMatches + " correspondances pour " + hash); Les sources classiques sont window.location.search (la query string) et window.location.hash (le fragment d'URL). Tant qu'aucun puits ne consomme la valeur de façon dangereuse, rien ne se passe ; c'est la rencontre d'une source et d'un puits qui crée la faille. Ce type de XSS est presque impossible à repérer par analyse statique, et il dépend des implémentations du DOM propres à chaque navigateur : une version peut être vulnérable, une autre non.
XSS par mutation (mXSS)
Le XSS par mutation (mutation-based XSS, mXSS) est la variante la plus récente et la plus subtile. Son principe : un payload conforme aux règles du filtre au moment de la sanitisation, mais qui mute en payload dangereux une fois que le navigateur l'optimise en l'insérant dans le DOM réel. Hoffman rapporte qu'un mXSS a contourné des outils réputés comme DOMPurify, OWASP AntiSamy ou Google Caja : le navigateur réorganise les balises selon des règles conditionnelles (parents, enfants, voisins) et fait apparaître un script là où le sanitiseur n'en voyait pas.
Note
Le mXSS rappelle une vérité plus large : les attaques de type XSS visent toute technologie d'affichage côté client. Elles se concentrent dans le navigateur, mais les technologies de bureau et mobiles peuvent aussi être vulnérables. De futures technologies le seront sans doute également.
Comment se défendre : la règle d'or
Le chapitre défensif du livre tient d'abord en une règle simple à graver dans l'équipe :
Ne laissez aucune donnée fournie par l'utilisateur passer dans le DOM — sauf en tant que chaîne de caractères.
Beaucoup d'applications ont des fonctionnalités qui doivent transférer du contenu utilisateur vers le DOM ; on précise alors la règle : ne laissez jamais passer dans le DOM une donnée utilisateur non sanitisée. Faire peupler le DOM par des données utilisateur doit rester un recours de dernier ressort, jamais le réflexe par défaut.
Préférer le texte au markup : textContent plutôt que innerHTML
Quand des balises HTML ne sont pas nécessaires, on injecte la donnée comme du texte pur. En JavaScript, innerText / textContent représente toute balise comme une chaîne, là où innerHTML l'interprète comme du markup.
// VULNÉRABLE : les balises sont interprétées comme du DOM.
const userString = "<strong>bonjour</strong>";
const div = document.querySelector("#userComment");
div.innerHTML = userString; // CORRIGÉ : les balises sont affichées telles quelles, comme du texte.
const userString = "<strong>bonjour</strong>";
const div = document.querySelector("#userComment");
div.textContent = userString; // équivalent sûr d'innerText Utiliser textContent (ou innerText) plutôt que innerHTML chaque fois qu'on ajoute une vraie chaîne au DOM est une bonne pratique de premier ordre. Hoffman note toutefois que la sanitisation d'innerText n'est pas infaillible — chaque navigateur a ses variantes —, mais elle élimine le cas le plus courant.
Attention
Fuyez les API qui convertissent du texte en DOM ou en script. Une bonne règle : tout ce qui transforme du texte en DOM ou en script est un puits XSS potentiel. À éviter quand c'est possible : element.innerHTML / element.outerHTML, document.write / document.writeln, DOMParser.parseFromString, document.implementation, ainsi que les Blob et SVG (voir plus bas).
Quand vous devez bâtir une structure depuis une chaîne, ne passez pas par DOMParser : créez chaque nœud avec document.createElement() et assemblez-les avec appendChild(). Vous contrôlez ainsi la structure et les noms de balises, et la donnée non fiable ne contrôle que le contenu, jamais la structure.
Sanitiser le HTML riche avec une bibliothèque éprouvée
Parfois, vous devez autoriser certaines balises (<strong>, <i>) mais pas d'autres (<script>). Là, textContent ne suffit plus : il faut sanitiser. Et la sanitisation manuelle est extrêmement difficile. Hoffman le démontre : même un filtre qui bloque les guillemets et les balises <script> laisse passer une URL exploitant le pseudo-schéma JavaScript.
<!-- VULNÉRABLE : pas de balise script, pourtant exécutable -->
<a href="javascript:alert(document.cookie)">cliquez-moi</a> Le pseudo-schéma javascript: exécute une chaîne sans aucune balise <script> ni guillemet. Et avec String.fromCharCode(), un attaquant reconstruit même les caractères filtrés. Conclusion du livre : n'écrivez pas votre propre sanitiseur. Utilisez une bibliothèque éprouvée et largement testée comme DOMPurify.
// CORRIGÉ : sanitisation par une bibliothèque éprouvée.
import DOMPurify from "dompurify";
const sale = '<strong>ok</strong><img src=x onerror=voler()>';
const propre = DOMPurify.sanitize(sale, {
ALLOWED_TAGS: ["strong", "i", "em", "a"],
ALLOWED_ATTR: ["href"],
});
document.querySelector("#bio").innerHTML = propre; À retenir
Centralisez la sanitisation. L'application devrait disposer d'une fonction unique pour ajouter du contenu au DOM, afin que la sanitisation soit systématique partout. Une règle dispersée sera, tôt ou tard, oubliée à un endroit — et c'est là que naîtra la faille.
Assainir les hyperliens via le navigateur
Pour les liens construits à partir d'entrées utilisateur, profitez du filtrage très robuste que les navigateurs appliquent aux balises <a>. En affectant l'entrée à l'attribut href d'un élément <a> factice, le navigateur normalise et neutralise le schéma et les caractères dangereux.
// VULNÉRABLE : l'entrée utilisateur file droit dans l'URL.
const userLink = "<script>voler()</script>";
const goToLink = function () {
window.location.href = `https://monsite.com/${userLink}`;
}; // CORRIGÉ : on s'appuie sur la sanitisation native de <a>.
const userLink = "<script>voler()</script>";
const goToLink = function () {
const dummy = document.createElement("a");
dummy.href = userLink; // le navigateur normalise et encode
window.location.href = `https://monsite.com/${dummy.pathname}`;
}; Pour un fragment d'URL, encodeURIComponent() encode les caractères dangereux d'une portion :
encodeURIComponent("<strong>test</strong>");
// "%3Cstrong%3Etest%3C%2Fstrong%3E" Attention : encodeURIComponent() ne peut pas encoder une URL entière, car l'origine (schéma + :// + hôte + port) ne serait plus interprétable par le navigateur.
Encodage en sortie selon le contexte
La parade fondamentale est l'encodage en sortie (output encoding), et il dépend du contexte d'insertion. Pour le contenu HTML, on applique l'encodage des entités HTML (HTML entity encoding) : on remplace les caractères de markup par leur entité, de sorte qu'ils s'affichent sans jamais être interprétés. Les « cinq grands » du livre :
| Caractère | Entité encodée |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
Le rendu visuel ne change pas (& s'affiche « & »), mais le risque d'exécution chute fortement. Point crucial souligné par Hoffman : l'encodage d'entités HTML ne protège PAS le contenu inséré dans une balise <script>, dans du CSS ou dans une URL. Il ne protège que le contenu placé dans un nœud de type <div>. D'où l'importance du contexte :
| Contexte d'insertion | Encodage / défense adaptés |
|---|---|
Corps HTML (<div>…) | Encodage des entités HTML (les 5 grands) |
| Valeur d'attribut HTML | Encodage d'attribut + guillemets obligatoires |
| Code JavaScript | Ne pas injecter ; sinon échappement JS strict |
URL / href | encodeURIComponent, schéma validé via <a> |
Le drapeau HttpOnly sur les cookies de session
Une cible privilégiée du XSS est le jeton de session stocké dans un cookie. Marquer ce cookie avec le drapeau HttpOnly le rend inaccessible au JavaScript : document.cookie ne le voit plus. Même si un script malveillant s'exécute, il ne peut pas lire le cookie de session pour l'exfiltrer.
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict Ce n'est pas une défense contre le XSS lui-même — le script s'exécute toujours — mais une réduction d'impact essentielle : elle ferme la voie la plus directe vers la prise de contrôle de compte par vol de session.
La Content Security Policy comme filet de sécurité
La politique de sécurité de contenu (Content Security Policy, CSP) est un outil de configuration supporté par tous les navigateurs majeurs. Elle indique au navigateur quels scripts ont le droit de s'exécuter, et d'où ils peuvent être chargés. Comme c'est le navigateur qui l'applique, elle est difficile à contourner.
Le levier central est script-src, qui établit une liste blanche des origines autorisées à charger des scripts dynamiques :
Content-Security-Policy: script-src 'self' https://api.mega-bank.com Avec cette règle, un script venu de https://www.hacker.com ne se chargera pas : le navigateur lèvera une violation de CSP. Le mot-clé 'self' désigne l'origine du document protégé.
Attention
Méfiez-vous des jokers (wildcards) comme https://*.mega-bank.com. Ils paraissent commodes, mais si un sous-domaine venait un jour à héberger du contenu téléversé par des utilisateurs (par exemple hosting.mega-bank.com), ce filet trop large deviendrait une porte ouverte. Toute liste blanche à joker porte un risque intrinsèque.
Par défaut, lorsque la CSP est active, le script inline est désactivé et eval() (ainsi que les fonctions équivalentes texte → code) l'est aussi. On ne réactive 'unsafe-inline' et 'unsafe-eval' qu'en connaissance de cause — et de préférence jamais. Plutôt que de réactiver eval, réécrivez le code pour ne plus passer une chaîne à interpréter :
// VULNÉRABLE : une chaîne est interprétée comme du code.
const startTimer = function (minutes, message) {
setTimeout(`window.alert(${message});`, minutes * 60000);
}; // CORRIGÉ : on passe une vraie fonction, pas une chaîne.
const startTimer = function (minutes, message) {
setTimeout(function () {
alert(message);
}, minutes * 60000);
}; On déploie la CSP via l'en-tête Content-Security-Policy renvoyé à chaque requête, ou via une balise <meta http-equiv="Content-Security-Policy">. Hoffman conseille de la poser dès le début du projet : si vous savez quelles API et quelles sources votre application utilisera, écrivez la bonne politique d'emblée — elle se modifie facilement plus tard.
Piège courant
La CSP est un excellent filet de sécurité contre le XSS courant, mais elle ne protège pas contre le XSS basé sur le DOM, qui s'exécute via des puits internes sans charger de script externe. Elle complète, mais ne remplace pas, l'encodage en sortie et la sanitisation. Une application correctement protégée combine plusieurs de ces couches.
Les frameworks modernes et leurs pièges
React, Vue et Angular échappent les données par défaut : interpoler une variable dans un template produit du texte, pas du markup. C'est un acquis défensif majeur — à condition de ne pas désactiver soi-même cette protection. Or chaque framework offre une porte de sortie qui réintroduit le risque XSS si on l'utilise sur une donnée non fiable.
// VULNÉRABLE (React) : on force l'injection de HTML brut.
function Bio({ texte }) {
return <div dangerouslySetInnerHTML={{ __html: texte }} />;
} // CORRIGÉ : interpolation normale = échappement automatique.
function Bio({ texte }) {
return <div>{texte}</div>;
} // CORRIGÉ : si du HTML riche est requis, sanitiser d'abord.
import DOMPurify from "dompurify";
function Bio({ texte }) {
const html = DOMPurify.sanitize(texte);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
} Le piège porte un nom différent selon l'outil, mais le mécanisme est identique :
| Framework | Porte de sortie risquée | Réflexe sûr |
|---|---|---|
| React | dangerouslySetInnerHTML | Interpolation {valeur}, sinon DOMPurify |
| Vue | v-html | {{ valeur }} (moustache), sinon DOMPurify |
| Angular | bypassSecurityTrustHtml | Liaison [textContent], DomSanitizer |
L'adjectif « dangerously » dans dangerouslySetInnerHTML n'est pas décoratif : il signale exactement le moment où vous quittez le terrain protégé. Si vous l'employez, la donnée doit impérativement avoir traversé un sanitiseur éprouvé au préalable.
Tableau récapitulatif : menace → contre-mesure
| Type de XSS | Vecteur | Contre-mesure principale |
|---|---|---|
| Stocké | Donnée persistée (commentaire) puis servie | Encodage en sortie + sanitisation centralisée |
| Réfléchi | Paramètre de requête renvoyé non échappé | Encodage selon le contexte côté serveur |
| Basé sur le DOM | Source (location.hash) → puits (innerHTML) | textContent, éviter les puits, createElement |
| Par mutation | Payload « sûr » qui mute dans le DOM | Sanitiseur maintenu à jour + CSP en défense |
| Vol de session | Lecture de document.cookie | Cookie HttpOnly (+ Secure, SameSite) |
| Toute exécution inline | Script injecté dans la page | CSP : script-src 'self', pas d'unsafe-inline |
Aucune de ces lignes ne suffit isolément. Le XSS se neutralise par défense en profondeur : on échappe en sortie, on sanitise le HTML riche, on évite les puits dangereux, on verrouille les cookies, et on pose une CSP par-dessus le tout.
À retenir
- La cause racine est unique : des données non fiables interprétées comme du code ou du markup. Tout l'enjeu est de maintenir la frontière entre données et code.
- Trois familles à connaître : stocké (persisté côté serveur), réfléchi (renvoyé depuis la requête), basé sur le DOM (source → puits côté client), plus la mutation (mXSS) qui contourne les filtres.
- Le texte d'abord : préférez
textContentàinnerHTML, fuyezeval,document.write,DOMParser,BlobetSVG; construisez le DOM aveccreateElement. - N'écrivez pas votre sanitiseur : pour du HTML riche, passez par une bibliothèque éprouvée comme DOMPurify, et centralisez l'opération.
- Encodez selon le contexte : entités HTML pour le corps, encodage d'attribut,
encodeURIComponentpour les URL — chaque contexte a sa règle. - Empilez les filets :
HttpOnlysur les cookies de session, CSP avecscript-srcrestrictif sans inline, et vigilance surdangerouslySetInnerHTML/v-html. C'est la défense en profondeur qui protège.