Cross-site scripting (XSS) : mécanisme, variantes et défenses
Section intitulée « Cross-site scripting (XSS) : mécanisme, variantes et défenses »Le cross-site scripting consiste à faire exécuter son propre code par le navigateur d’une victime, au nom d’un site qu’elle croit digne de confiance. La faille ne vit pas dans le navigateur, qui obéit scrupuleusement aux règles, mais dans l’application qui renvoie une donnée comme si c’était du code. Elle traverse les décennies, les frameworks et les modes avec une constance qui force le respect.
Un inconnu glisse une feuille dans le carnet de correspondance officiel du lycée. Les parents la lisent, voient le tampon de l’établissement et suivent les consignes sans se méfier. Le problème ne vient pas des parents, mais du carnet qui a accepté une page étrangère. Cette page montre comment cela arrive sur un site web et comment l’empêcher.
→ Lire l’explication complète de la marmotte
Même origine : pourquoi le script XSS hérite des droits du site
Section intitulée « Même origine : pourquoi le script XSS hérite des droits du site »Le navigateur cloisonne les sites par origine : le triplet schéma, hôte, port (https://app.example.com:443). Un script n’a accès qu’aux ressources de l’origine qui l’a chargé. C’est la politique de même origine, et c’est elle qui empêche un onglet quelconque de lire la messagerie ouverte dans l’onglet voisin.
Le cross-site scripting, abrégé XSS (Cross-Site Scripting), ne contourne pas cette règle : il s’y installe. Quand une page de app.example.com contient un <script> injecté, le navigateur n’a aucun moyen de distinguer ce script de ceux écrits par l’équipe de développement. Il appartient à l’origine, donc il reçoit tous ses droits. Dans une session ouverte, cela donne à l’attaquant :
- la lecture de la page : tout le DOM (Document Object Model, l’arbre des éléments de la page), donc les données affichées, les jetons contre le CSRF (Cross-Site Request Forgery, falsification de requête intersite) cachés dans les formulaires, les identifiants techniques ;
- des requêtes au nom de la victime :
fetch()vers la même origine part avec les cookies de session, et la réponse est lisible par le script. Changer l’adresse de messagerie du compte, créer une clé d’API (Application Programming Interface), exporter des données : tout ce que l’interface permet ; - un faux formulaire : le script réécrit la page pour afficher une demande de ressaisie du mot de passe, servie depuis le vrai domaine, avec le vrai certificat et le vrai cadenas ;
- le vol des cookies non HttpOnly via
document.cookie, puis leur rejeu depuis une autre machine ; - le contenu du stockage local (
localStorage,sessionStorage), où certaines applications monopages rangent encore leurs jetons d’accès.
L’authentification forte n’y change rien. Le MFA (Multi-Factor Authentication), la clé physique, la passkey protègent le moment où la session s’ouvre. Le XSS intervient après, dans une session déjà authentifiée, et n’a pas besoin du second facteur puisque le navigateur de la victime fait le travail. Une politique qui exige une réauthentification pour les actions sensibles limite les dégâts sur ces actions précises ; elle ne protège pas la lecture des données affichées.
Ce que la politique de même origine fait au XSS, en une figure : l’injection rend la frontière inopérante parce que le code malveillant est déjà du bon côté.
Trois familles de XSS : réfléchi, stocké, fondé sur le DOM
Section intitulée « Trois familles de XSS : réfléchi, stocké, fondé sur le DOM »La classification courante répond à une seule question : où la charge transite-t-elle avant d’être exécutée ? La réponse détermine qui peut la voir passer, donc qui peut la bloquer.
XSS réfléchi
Section intitulée « XSS réfléchi »La charge arrive dans la requête (paramètre d’URL, abréviation d’Uniform Resource Locator, champ de formulaire, en-tête) et le serveur la recopie dans sa réponse sans encodage. Point d’injection typique : la page de résultats de recherche qui affiche « Aucun résultat pour terme », ou la page d’erreur qui rappelle l’URL demandée.
https://shop.example.com/recherche?q=<script>alert(document.domain)</script>La victime doit cliquer sur un lien piégé. Ce n’est pas une limite sérieuse : un lien raccourci dans un courriel bien tourné suffit.
XSS stocké
Section intitulée « XSS stocké »La charge est enregistrée par l’application (base de données, fichier, file de messages) puis servie à tous les utilisateurs qui consultent la ressource. Points d’injection typiques : commentaires, nom d’affichage, titre d’un ticket, nom de fichier téléversé, champ « bio » d’un profil. C’est la famille la plus rentable pour l’attaquant : aucune interaction à provoquer, et la cible peut être choisie en visant les écrans consultés par les administrateurs.
XSS fondé sur le DOM
Section intitulée « XSS fondé sur le DOM »Le serveur n’y est pour rien. Du code JavaScript côté client lit une source contrôlable par l’attaquant (location.hash, location.search, postMessage, document.referrer, window.name) et l’écrit dans un puits qui interprète du code : innerHTML, outerHTML, document.write, eval, setTimeout avec une chaîne, attribut href en javascript:.
// Puits dangereux : le fragment d'URL devient du HTMLdocument.getElementById("onglet").innerHTML = decodeURIComponent(location.hash.slice(1));Le fragment après # n’est jamais envoyé au serveur. Aucun journal, aucun filtre côté serveur ne le voit passer. Les applications monopages ont déplacé une bonne part du risque ici, ce qui n’empêche pas les plaquettes de présenter le rendu client comme une protection naturelle contre le XSS.
XSS par mutation
Section intitulée « XSS par mutation »Variante plus retorse : un fragment HTML (HyperText Markup Language) est assaini, jugé inoffensif, puis réinterprété par le navigateur au moment de l’insertion dans le document. L’analyseur HTML corrige les balises mal fermées, change d’espace de noms en entrant dans du SVG (Scalable Vector Graphics) ou du MathML, déplace des éléments. Le résultat de cette « réparation » peut contenir ce que l’assainisseur avait retiré. La leçon d’architecture : un assainisseur doit travailler sur l’arbre que le navigateur construira réellement, pas sur une chaîne de caractères. C’est pour cela que les bibliothèques sérieuses s’appuient sur l’analyseur du navigateur lui-même, et que leurs correctifs de sécurité doivent être suivis comme ceux d’un système d’exploitation.
Un cas documenté : le ver Samy sur MySpace
Section intitulée « Un cas documenté : le ver Samy sur MySpace »En octobre 2005, Samy Kamkar a publié sur son profil MySpace un XSS stocké. Tout visiteur du profil exécutait le script, qui ajoutait Samy comme ami et recopiait le code dans le profil du visiteur, avec la phrase « but most of all, samy is my hero ». Chaque profil infecté devenait à son tour vecteur. En moins d’une journée, plus d’un million de comptes étaient touchés, et MySpace a dû interrompre le service pour le nettoyer.
Le point qui intéresse l’architecte n’est pas le folklore. MySpace filtrait le contenu des profils par liste noire : la chaîne javascript était retirée, certaines balises interdites. Le ver contournait ces filtres en exploitant la tolérance des navigateurs de l’époque (un retour à la ligne au milieu du mot javascript dans une propriété CSS, abréviation de Cascading Style Sheets) et en reconstruisant les caractères interdits à l’exécution. Il a aussi enchaîné des requêtes authentifiées, en lisant les jetons nécessaires dans les pages de la victime : exactement la capacité décrite plus haut. Vingt ans plus tard, la conclusion n’a pas pris une ride : une liste noire perd toujours contre un analyseur plus permissif qu’elle.
Défenses contre le XSS, dans l’ordre où elles comptent
Section intitulée « Défenses contre le XSS, dans l’ordre où elles comptent »Le compromis central tient en une phrase : la seule défense qui ferme la faille est l’encodage correct à chaque point de sortie, et il se paie en discipline sur toute la base de code ; tout le reste (CSP, cookies, WAF) achète du temps et réduit les dégâts, sans corriger quoi que ce soit. La recommandation qui en découle : investir d’abord dans un rendu qui encode par défaut et dans la chasse aux échappatoires, ensuite seulement dans les couches de confinement.
Encodage selon le contexte de sortie
Section intitulée « Encodage selon le contexte de sortie »On encode à la sortie, pas à l’entrée. Encoder à l’entrée suppose de connaître la destination de la donnée au moment où elle arrive ; or la même chaîne finira dans une page HTML, un export CSV, une notification push et un journal. Chaque destination a ses caractères dangereux :
| Contexte de sortie | Caractères à neutraliser | Piège classique |
|---|---|---|
| Corps HTML | <, >, & | Encodage oublié dans un gabarit d’erreur |
| Attribut HTML | Les précédents plus " et ' | Attribut non délimité par des guillemets |
| Chaîne JavaScript | \, guillemets, </script>, sauts de ligne | Encodage HTML appliqué dans un <script> : il ne protège rien |
| URL | Encodage pourcent du paramètre ; schéma vérifié | href="javascript:..." passe l’encodage HTML sans broncher |
La dernière ligne mérite insistance : un lien construit à partir d’une donnée utilisateur doit voir son schéma contrôlé par liste blanche (https:, mailto:). L’encodage HTML d’une URL javascript: produit une URL javascript: parfaitement encodée.
Pour placer une donnée à la disposition du JavaScript, le chemin le plus sûr évite d’écrire dans un bloc de script : la sérialiser en JSON (JavaScript Object Notation) dans un attribut data- ou un <script type="application/json">, puis la relire avec JSON.parse.
Gabarits qui échappent par défaut, et leurs échappatoires
Section intitulée « Gabarits qui échappent par défaut, et leurs échappatoires »Jinja2 avec l’échappement automatique activé, Twig, Razor, les templates Go html/template, JSX (JavaScript XML, la syntaxe de gabarits de React) dans React, les moustaches de Vue et Angular encodent par défaut. C’est le plus gros progrès du domaine depuis vingt ans. Chacun fournit aussi une échappatoire, et c’est là que vivent les XSS modernes :
innerHTML,outerHTML,insertAdjacentHTML,document.writeen JavaScript natif ;dangerouslySetInnerHTMLen React (le nom avait pourtant fait l’effort) ;v-htmlen Vue ;bypassSecurityTrustHtmlen Angular ;- le filtre
|safeouMarkup()en Jinja2,|rawen Twig,@Html.Rawen Razor,template.HTMLen Go.
Règle de revue : chaque occurrence de ces échappatoires doit pointer vers une donnée soit constante, soit passée par un assainisseur dans la même fonction. Une échappatoire dont la donnée vient de trois appels plus haut est un ticket ouvert, pas une ligne de code.
Assainissement du HTML riche
Section intitulée « Assainissement du HTML riche »Quand le produit exige que l’utilisateur fournisse du HTML (éditeur riche, signature de courriel, description formatée), l’encodage n’est plus possible : on assainit, avec une bibliothèque éprouvée et maintenue, en liste blanche de balises et d’attributs. DOMPurify côté navigateur, la bibliothèque HTML Sanitizer de l’OWASP (Open Worldwide Application Security Project) en Java, bleach en Python (officiellement déprécié par ses mainteneurs, donc à remplacer, par exemple par nh3) côté serveur. Écrire son propre assainisseur à base d’expressions régulières est une tradition du secteur, au même titre que réécrire son propre mode de chiffrement, et pour exactement les mêmes résultats.
Trois conditions de validité : assainir au plus près de l’insertion (une donnée assainie puis modifiée ne l’est plus) ; suivre les mises à jour, car les contournements par mutation sont corrigés version après version ; restreindre la liste blanche au besoin réel, <b>, <i>, <a> et <ul> couvrent la plupart des usages.
Trusted Types
Section intitulée « Trusted Types »Trusted Types transforme les puits dangereux du DOM en erreurs de type : une fois activé par l’en-tête Content-Security-Policy: require-trusted-types-for 'script', innerHTML refuse les chaînes brutes et n’accepte que des objets produits par une politique déclarée, typiquement adossée à un assainisseur. L’intérêt pour l’architecte est la centralisation : les points où du HTML entre dans le DOM se réduisent à quelques politiques auditables au lieu de centaines d’appels dispersés. La prise en charge a longtemps été limitée aux navigateurs fondés sur Chromium ; on vérifie la matrice de compatibilité avant d’en faire autre chose qu’une couche supplémentaire, et on démarre en mode rapport (Content-Security-Policy-Report-Only) pour inventorier les violations.
CSP stricte à nonces, en seconde ligne
Section intitulée « CSP stricte à nonces, en seconde ligne »La CSP (Content Security Policy) indique au navigateur quels scripts il a le droit d’exécuter. Les politiques à liste de domaines (script-src https://cdn.example.com) sont largement contournables dès qu’un domaine autorisé héberge un point JSONP (JSON with Padding) ou une bibliothèque exploitable. La forme qui résiste est la CSP stricte à nonces :
Content-Security-Policy: script-src 'nonce-R4nd0mParReponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'Le nonce, imprévisible et différent à chaque réponse, est posé sur chaque <script> légitime ; un script injecté ne le connaît pas. 'strict-dynamic' étend la confiance aux scripts chargés par un script déjà autorisé. Conditions d’exploitation : générer le nonce côté serveur à chaque réponse (un nonce figé dans une page mise en cache ne vaut rien), interdire les gestionnaires inline (onclick=), surveiller les rapports de violation. Une CSP qui contient 'unsafe-inline' sans nonce n’arrête aucun XSS classique. Et même stricte, elle laisse passer l’injection de HTML pur (faux formulaire, exfiltration par balise d’image si img-src est permissif) : seconde ligne, donc.
Cookies HttpOnly et SameSite
Section intitulée « Cookies HttpOnly et SameSite »HttpOnly empêche document.cookie de lire le cookie de session. Le vol pour rejeu depuis une autre machine devient impossible ; l’attaquant doit agir pendant que la victime a l’onglet ouvert. Cela n’empêche pas fetch() d’envoyer ce même cookie automatiquement : les actions au nom de la victime restent possibles. SameSite=Lax ou Strict protège contre les requêtes provoquées depuis un autre site, ce qui vise le CSRF ; un script injecté dans le site lui-même émet des requêtes de même site, non concernées. Les deux attributs sont indispensables et ne corrigent pas le XSS. Les jetons d’accès rangés dans localStorage n’ont pas d’équivalent HttpOnly : tout script de l’origine les lit.
Pourquoi un WAF ne suffit pas
Section intitulée « Pourquoi un WAF ne suffit pas »Le WAF (Web Application Firewall) inspecte les requêtes à la recherche de motifs connus. Il ignore le contexte de sortie, qui seul détermine si une chaîne est dangereuse ; il ne voit pas le fragment d’URL des XSS fondés sur le DOM ; il perd face aux charges encodées en couches, transportées en JSON ou en base64, découpées sur plusieurs champs réassemblés côté application ; et ses règles génériques bloquent tôt ou tard un client légitime qui écrit <script> dans un ticket de support, ce qui finit en exception permanente. Le WAF a sa place, réglé sans casser les fonctions métier, pour gagner quelques jours entre la découverte d’une faille et son correctif. En faire la défense principale, c’est le ver Samy contre un filtre à liste noire, avec un abonnement annuel.
Vérifier qu’une application résiste au XSS
Section intitulée « Vérifier qu’une application résiste au XSS »La vérification part des sorties, pas des entrées. Les entrées sont innombrables ; les endroits où une donnée devient du HTML, du JavaScript ou une URL sont dénombrables.
- Inventaire des puits : rechercher dans le code toutes les échappatoires listées plus haut, plus
eval,new Function,setTimeoutavec chaîne,location =ethrefconstruits dynamiquement. Chaque occurrence se justifie ou se corrige. - Vérification de la configuration des gabarits : l’échappement automatique doit être actif globalement, y compris pour les gabarits de courriel, de PDF et d’erreur, souvent rendus par un autre moteur que l’application principale.
- Tests : des tests unitaires qui injectent
<,",'etjavascript:dans chaque champ affiché ; une analyse statique avec des règles de flux source vers puits ; un test dynamique sur les écrans authentifiés, y compris d’administration. - En-têtes : contrôler la présence et la teneur de la CSP (une politique avec
'unsafe-inline'et sans nonce équivaut à une absence de CSP contre le XSS), les attributs de cookies, etX-Content-Type-Options: nosniff, dont l’absence rouvre la porte décrite dans l’attaque par MIME sniffing.
Pièges fréquents
Section intitulée « Pièges fréquents »- Rendu Markdown : la plupart des moteurs Markdown laissent passer le HTML brut par défaut, et les liens
[texte](javascript:...). Désactiver le HTML brut, filtrer les schémas, assainir la sortie. Le même rendu, dans une interface de conversation avec un modèle de langage, sert aussi à exfiltrer des données par des images ; voir l’exfiltration par rendu Markdown dans un chat IA. - Éditeurs riches : ils produisent du HTML côté client, que l’API reçoit tel quel. L’assainissement doit se faire côté serveur ou à l’affichage, jamais seulement dans l’éditeur, que l’attaquant contourne en appelant l’API directement.
- Champs d’administration : « seuls les administrateurs y ont accès » n’est pas une protection, c’est la description de la cible. Un XSS stocké dans un champ affiché dans le back-office est exactement ce qu’un attaquant cherche, et les extensions tierces de CMS (Content Management System) en fournissent régulièrement.
- Contenus servis depuis un autre domaine : les fichiers téléversés par les utilisateurs doivent être servis depuis un domaine distinct (
usercontent.example.net, pas un sous-domaine qui partage les cookies), avecContent-Disposition: attachmentquand l’affichage n’est pas nécessaire. Un SVG téléversé est un document capable d’exécuter du script ; voir l’upload de shell déguisé. - Pollution de prototype : en JavaScript, elle sert fréquemment de marchepied vers un XSS fondé sur le DOM en injectant une propriété lue par une bibliothèque ; voir la pollution de prototype.
- Caches et CDN : un XSS réfléchi mis en cache par un CDN (Content Delivery Network) devient un XSS stocké servi à tous ; le mécanisme voisin est décrit dans la tromperie de cache CDN.
Ce que les défenses contre le XSS ne garantissent pas
Section intitulée « Ce que les défenses contre le XSS ne garantissent pas »L’encodage correct supprime l’injection de script ; il ne dit rien de l’autorisation. Un utilisateur autorisé à modifier un lien affiché à d’autres peut toujours y mettre une URL de phishing en https: parfaitement légitime. La CSP ne protège ni contre l’injection de HTML sans script ni contre une bibliothèque tierce compromise chargée avec un nonce valide : l’intégrité de la chaîne d’approvisionnement relève de la gestion des dépendances et de l’attribut integrity (SRI, Subresource Integrity). HttpOnly ne protège pas les actions en session. Et aucune de ces mesures ne remplace la détection : les rapports CSP envoyés vers un point de collecte supervisé sont souvent le premier signal qu’une injection a lieu, à condition que quelqu’un les lise. Le reste du catalogue est rangé dans la rubrique Failles ; pour relier ces constats à un rapport d’outil, voir comment lire un rapport OWASP Top 10.
Exemple : trajet d’un XSS stocké et correctif
Section intitulée « Exemple : trajet d’un XSS stocké et correctif »Le schéma suit un XSS stocké dans un champ de commentaire, de la publication de la charge à l’exfiltration. On y lit que le serveur ne fait que stocker puis restituer : la faille se matérialise dans le navigateur de la victime, et l’attaquant récupère le résultat sans jamais toucher la session lui-même.
Le correctif côté client, quand le commentaire n’a pas besoin de mise en forme :
// Avant : le commentaire est interprété comme du HTML (et exécuté)liste.innerHTML += `<li>${commentaire.texte}</li>`;
// Après : le commentaire reste du texte, quel que soit son contenuconst li = document.createElement("li");li.textContent = commentaire.texte;liste.appendChild(li);Avec la CSP stricte présentée plus haut, l’étape de chargement de x.js échoue même si l’encodage a été oublié ailleurs ; avec connect-src 'self', l’exfiltration par fetch aussi. L’encodage ferme la faille, la CSP limite ce que la suivante pourra faire.
🐝 Ce qui arrive si on s’en passe
Section intitulée « 🐝 Ce qui arrive si on s’en passe »Sans encodage à la sortie, chaque action du script injecté part de la session légitime de la victime, depuis son adresse IP et son navigateur : les journaux serveur l’attribuent à la victime, et rien ne distingue après coup une modification frauduleuse d’une modification voulue. La traçabilité ne sert alors ni à l’enquête ni à la contestation. Les effets durent aussi plus longtemps que la session : une clé d’API ou un compte créé par le script reste valide après la déconnexion et le changement de mot de passe, tant que personne ne le révoque explicitement. Enfin, le navigateur accorde certaines autorisations par origine (géolocalisation, caméra, microphone, notifications) : si l’utilisateur les a déjà données au site, le script injecté les utilise sans nouvelle demande.
🦫 Explication de la marmotte 🍫
Section intitulée « 🦫 Explication de la marmotte 🍫 »Un site web envoie à ton navigateur une page faite de texte et de petits programmes. Le navigateur exécute ces programmes en leur donnant accès à tout ce qui concerne ce site : ton compte, tes messages, tes réglages. Il ne sait pas qui les a écrits ; il sait seulement de quel site ils viennent. Le problème commence quand un site recopie dans sa page quelque chose qu’un inconnu a tapé, par exemple un commentaire, sans vérifier que ce texte ne contient pas de programme. Si l’inconnu a écrit un programme à la place d’un commentaire, ton navigateur l’exécute comme s’il venait du site. Ce programme peut alors lire ce qui s’affiche, envoyer des demandes à ta place, afficher une fausse demande de mot de passe ou envoyer tes informations ailleurs. Le code reçu par SMS au moment de te connecter n’empêche rien : tu étais déjà connecté. La vraie protection consiste, pour le site, à transformer chaque caractère dangereux en caractère inoffensif avant de l’afficher, à chaque endroit où il affiche une donnée venue de quelqu’un. Ensuite viennent des protections de secours : une règle qui dit au navigateur quels programmes sont autorisés, et des réglages qui empêchent les programmes de lire le badge de connexion. Un filtre posé devant le site aide un peu, mais il rate toujours des cas, car il ne voit pas où chaque texte finira dans la page.
À lire aussi
- Comment corriger une attaque path traversalDémonstration d'une attaque path traversal sur un téléchargement vulnérable et des contrôles nécessaires pour empêcher la lecture ou l'écriture hors périmètre.
- Comment empêcher une attaque de fixation de sessionDémonstration en laboratoire de l’attaque de fixation de session et contre-mesure par régénération de l’identifiant après authentification.
- JavaScript et SQL côté web, ce qu'il faut savoir des deux langagesCe qu'il faut connaître du modèle d'exécution de JavaScript et de SQL pour lire un code web, relire une revue de sécurité ou interpréter un rapport de scan.
- L'injection de prompt indirecte en une pageComment un courriel piégé fait obéir un agent d'IA outillé, en six étapes animées, avec les parades qui coupent la chaîne et la limite qu'aucun filtre ne lève.
Pages voisines
La citent
- Comment empêcher une attaque de fixation de session
- Attaque par confusion de scope OAuth
- Faille Facebook Voir en tant que 2018 et vol de jetons d'accès
- Gérer la durée de vie des jetons OAuth
Cross-site scripting (XSS) : mécanisme, variantes et défenses
Elle cite
- Lien dans les deux sens :Attaque MIME sniffing par fichier téléversé
- Lien dans les deux sens :Upload de shell PHP déguisé en image dans un CMS
- Lien dans les deux sens :Attributs du cookie de session
- Lien dans les deux sens :Exfiltration par rendu Markdown dans un chat IA
- Attaque cache deception CDN et contre-mesures
- Démonstration de pollution de prototype JavaScript
- Comment lire un rapport OWASP Top 10
- Configurer un WAF sans casser les fonctions métier
: les deux pages se citent l’une l’autre.
