Web Application Security
Chapitre 3 / 9 · 15 min de lecture

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èreEntité 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'insertionEncodage / défense adaptés
Corps HTML (<div>…)Encodage des entités HTML (les 5 grands)
Valeur d'attribut HTMLEncodage d'attribut + guillemets obligatoires
Code JavaScriptNe pas injecter ; sinon échappement JS strict
URL / hrefencodeURIComponent, 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 :

FrameworkPorte de sortie risquéeRéflexe sûr
ReactdangerouslySetInnerHTMLInterpolation {valeur}, sinon DOMPurify
Vuev-html{{ valeur }} (moustache), sinon DOMPurify
AngularbypassSecurityTrustHtmlLiaison [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 XSSVecteurContre-mesure principale
StockéDonnée persistée (commentaire) puis servieEncodage en sortie + sanitisation centralisée
RéfléchiParamètre de requête renvoyé non échappéEncodage selon le contexte côté serveur
Basé sur le DOMSource (location.hash) → puits (innerHTML)textContent, éviter les puits, createElement
Par mutationPayload « sûr » qui mute dans le DOMSanitiseur maintenu à jour + CSP en défense
Vol de sessionLecture de document.cookieCookie HttpOnly (+ Secure, SameSite)
Toute exécution inlineScript injecté dans la pageCSP : 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, fuyez eval, document.write, DOMParser, Blob et SVG ; construisez le DOM avec createElement.
  • 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, encodeURIComponent pour les URL — chaque contexte a sa règle.
  • Empilez les filets : HttpOnly sur les cookies de session, CSP avec script-src restrictif sans inline, et vigilance sur dangerouslySetInnerHTML / v-html. C'est la défense en profondeur qui protège.