Aller au contenu

Cross-site scripting (XSS) : mécanisme, variantes et défenses

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.

→ 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é.

Schéma : trois scripts envoient une flèche vers le navigateur. Le script légitime de app.example.com y est exécuté avec les droits de ce site. Le script injecté dans une page de app.example.com est exécuté avec les mêmes droits. Le script de evil.example est séparé des deux autres par la frontière de la politique de même origine : il est bloqué pour la lecture de app.example.com. Le script injecté envoie à app.example.com des requêtes avec les cookies de session et peut lire les réponses. Il envoie ensuite les données lues à evil.example, sauf si une CSP restrictive sur connect-src l'en empêche.

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.

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.

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.

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 HTML
document.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.

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.

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.

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 sortieCaractères à neutraliserPiège classique
Corps HTML<, >, &Encodage oublié dans un gabarit d’erreur
Attribut HTMLLes précédents plus " et 'Attribut non délimité par des guillemets
Chaîne JavaScript\, guillemets, </script>, sauts de ligneEncodage HTML appliqué dans un <script> : il ne protège rien
URLEncodage 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.write en JavaScript natif ;
  • dangerouslySetInnerHTML en React (le nom avait pourtant fait l’effort) ;
  • v-html en Vue ;
  • bypassSecurityTrustHtml en Angular ;
  • le filtre |safe ou Markup() en Jinja2, |raw en Twig, @Html.Raw en Razor, template.HTML en 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.

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 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.

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.

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.

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.

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.

  1. Inventaire des puits : rechercher dans le code toutes les échappatoires listées plus haut, plus eval, new Function, setTimeout avec chaîne, location = et href construits dynamiquement. Chaque occurrence se justifie ou se corrige.
  2. 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.
  3. Tests : des tests unitaires qui injectent <, ", ' et javascript: 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.
  4. 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, et X-Content-Type-Options: nosniff, dont l’absence rouvre la porte décrite dans l’attaque par MIME sniffing.
  • 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), avec Content-Disposition: attachment quand 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.

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.

Diagramme de séquence entre six acteurs : Attaquant, Victime, Navigateur de la victime, Site, Base de données et evil.example. Étapes dans l'ordre : 1. l'attaquant publie sur le site un commentaire contenant une balise script pointant vers evil.example/x.js ; 2. le site l'enregistre tel quel en base de données ; 3. la victime, si sa session est authentifiée, ouvre la page des commentaires ; 4. le site lui renvoie la page HTML incluant le commentaire sans encodage ; 5. si aucune CSP restrictive n'est en place, le navigateur de la victime charge x.js depuis evil.example ; 6. il envoie au site un fetch vers /api/profil avec les cookies de session ; 7. le site répond avec les données du profil ; 8. si connect-src n'est pas restreint, le navigateur envoie les données lues à l'attaquant.

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 contenu
const 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.

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.

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

Pages voisines

: les deux pages se citent l’une l’autre.