Objectifs
À la fin de ce cours, tu seras capable de :
- Identifier les trois familles de XSS (réfléchie, stockée, DOM) et les couples source / sink côté client.
- Analyser le contexte de sortie d'une donnée (HTML, attribut, JavaScript, URL) et l'encodage qu'il exige.
- Démontrer une XSS avec une preuve inoffensive et acceptée par les programmes, sans collecte de données.
- Évaluer l'impact réel en tenant compte de la CSP, des attributs de cookie
HttpOnlyetSameSite, et des actions sensibles accessibles. - Rédiger un rapport qui distingue une XSS exploitable d'une self-XSS informative.
- Recommander une correction durable : templates auto-échappants, assainissement avec DOMPurify, Trusted Types et CSP stricte à nonce.
Pourquoi c'est important en bug bounty
Le Cross-Site Scripting (CWE-79) fait partie de la catégorie A03:2021 « Injection » de l'OWASP Top 10. C'est historiquement l'une des vulnérabilités les plus signalées sur les plateformes de bug bounty, et l'une des plus dupliquées : les formulaires de recherche évidents ont été testés des milliers de fois. Les découvertes intéressantes se trouvent désormais dans les applications monopages, les éditeurs riches, les intégrations tierces, les back-offices et les flux DOM complexes.
Sévérités typiques : moyenne pour une XSS réfléchie nécessitant un clic, élevée pour une XSS stockée touchant d'autres utilisateurs, critique lorsqu'elle se chaîne à une prise de contrôle de compte administrateur.
| Rapport fermé ou dévalué | Rapport payé |
|---|---|
| Self-XSS uniquement déclenchable par la victime sur son propre compte | XSS déclenchable chez une autre personne par un scénario réaliste |
alert(1) exécuté dans un domaine bac à sable hors périmètre | alert(document.domain) montrant l'origine exacte visée |
« Un attaquant pourrait voler les cookies » alors qu'ils sont HttpOnly | Impact démontré concrètement : action effectuée au nom de la victime de test |
| Injection HTML sans exécution de script, présentée comme XSS | Description précise du contexte, de la cause et de la correction |
Le mécanisme
Le navigateur applique la politique de même origine : un script chargé par app.acme.test peut lire le DOM, appeler les API et envoyer des requêtes authentifiées sur app.acme.test. Une XSS survient quand une donnée contrôlée par l'attaquant est insérée dans une page sans encodage adapté à son contexte, si bien que le navigateur l'interprète comme du code. Le script injecté s'exécute alors avec tous les privilèges de l'origine vulnérable : pour le navigateur, il est indiscernable du code légitime.
La cause racine est la même que pour l'injection SQL : mélange du code et des données, ici dans le langage HTML/JavaScript.
Les trois familles se distinguent par le trajet de la donnée :
- Réfléchie : la donnée vient de la requête (paramètre d'URL, champ de formulaire) et est renvoyée immédiatement dans la réponse. Il faut amener la victime à suivre un lien forgé.
- Stockée : la donnée est enregistrée (commentaire, profil, nom de fichier, ticket) puis affichée à d'autres utilisateurs. Aucune interaction particulière n'est requise au-delà de la visite de la page.
- DOM : le serveur n'est pas impliqué. Du JavaScript côté client lit une source contrôlable et l'écrit dans un sink dangereux.
| Sources courantes | Sinks dangereux |
|---|---|
location.hash, location.search | element.innerHTML, outerHTML |
document.referrer | document.write |
window.name | eval, setTimeout avec une chaîne, new Function |
messages postMessage | attribut href ou src construit dynamiquement |
localStorage, réponses d'API | méthodes HTML de bibliothèques (.html() de jQuery, dangerouslySetInnerHTML de React, v-html de Vue) |
Question de réflexion : pourquoi une application React est-elle souvent protégée, et quand ne l'est-elle plus ?
React encode automatiquement les valeurs insérées dans le JSX en tant que texte : une chaîne contenant des balises s'affiche littéralement. La protection disparaît dès que le développeur utilise dangerouslySetInnerHTML, qu'il construit un href à partir d'une donnée utilisateur (un schéma javascript: reste exécutable), qu'il manipule directement le DOM via une référence, ou qu'il injecte des données dans un script de configuration rendu côté serveur. Ce sont ces points qu'il faut chercher dans le code client.
Où chercher
- Paramètres reflétés : recherche, messages d'erreur, pages de redirection, paramètres de langue ou de thème.
- Contenu utilisateur riche : commentaires, biographies, éditeurs Markdown ou WYSIWYG, noms de fichiers téléversés, métadonnées d'images, fichiers SVG.
- Applications monopages : routes lues depuis le fragment
#, gestionnairespostMessagesans vérification d'origine, gabarits côté client. - Back-offices : tickets de support, formulaires de contact, journaux d'activité affichés aux administrateurs. C'est le terrain de la XSS aveugle : tu n'observes pas l'exécution, elle a lieu plus tard dans un outil interne.
- Exports et e-mails HTML générés à partir de données utilisateur.
- Widgets tiers et pages d'erreur personnalisées.
Signaux faibles : ta valeur réapparaît dans la réponse sans transformation des caractères <, >, " ou ' ; elle apparaît à l'intérieur d'un bloc script ou d'un attribut ; le code JavaScript minifié contient des affectations à innerHTML alimentées par l'URL.
Méthode de test
Teste uniquement dans le périmètre, avec tes comptes de test, et dans le cas d'une XSS stockée, sur des contenus que toi seul ou ton second compte de test pouvez voir lorsque c'est possible.
1. Injecter un marqueur unique. Utilise une chaîne alphanumérique repérable, puis cherche-la dans la réponse (et dans le DOM rendu, pour les applications monopages).
GET /search?q=fcx7k2 HTTP/1.1
Host: shop.techshop.test2. Identifier le contexte de chaque occurrence. Le marqueur peut apparaître :
Corps HTML : <p>Résultats pour fcx7k2</p>
Attribut : <input value="fcx7k2">
Chaîne JavaScript : var q = 'fcx7k2';
URL : <a href="/page?ref=fcx7k2">3. Tester les caractères de structure du contexte. Ajoute au marqueur les caractères qui permettraient de sortir du contexte (< et > en HTML, " dans un attribut, ' dans une chaîne JS) et observe s'ils sont encodés (<, ", \') ou renvoyés tels quels. Cette étape suffit souvent à conclure sur la présence d'une faille, avant toute exécution.
4. Confirmer par une preuve inoffensive. Si un caractère de structure passe, construis la preuve minimale adaptée au contexte : un appel à alert(document.domain), ou une modification visible et anodine comme le changement du titre de la page. Le choix de document.domain plutôt que alert(1) prouve dans quelle origine le code s'exécute, ce qui écarte les domaines bac à sable.
5. Pour une XSS DOM. Ouvre les outils de développement, place des points d'arrêt sur les sinks, ou utilise une extension d'analyse DOM (comme DOM Invader de Burp) pour suivre ton marqueur depuis la source jusqu'au sink. Note la ligne de code fautive pour le rapport.
6. Pour une XSS aveugle. Si le programme l'autorise, utilise une sonde qui signale simplement son exécution à un domaine de collaborateur (Burp Collaborator ou équivalent) en transmettant uniquement l'URL de la page et l'horodatage, jamais de cookies, de contenu de page ou de captures d'écran de données réelles. Signale ensuite rapidement l'exécution au programme.
7. Évaluer les protections. Lis l'en-tête Content-Security-Policy, les attributs des cookies de session et le mécanisme d'authentification des API (cookie ou jeton en en-tête) : ils conditionnent l'impact.
Variantes et défenses courantes
Encodage contextuel. Il n'existe pas d'encodage universel :
| Contexte | Encodage requis |
|---|---|
| Corps HTML | entités HTML (<, >, &) |
| Attribut entre guillemets | entités HTML, y compris " et ' |
| Chaîne JavaScript | échappement JavaScript (\xHH ou \uHHHH), ou mieux, données en JSON dans un élément séparé |
| URL | encodage pourcent du paramètre, et validation du schéma (https: uniquement) |
L'encodage HTML est inutile dans une chaîne JavaScript, et l'encodage d'URL n'empêche pas un schéma javascript: dans un href. C'est pourquoi les filtres « maison » échouent : ils appliquent une transformation unique à des contextes multiples.
Listes noires. Supprimer script ou onerror ne suffit jamais : il existe des dizaines d'éléments et de gestionnaires d'événements, et les navigateurs tolèrent de nombreuses variations syntaxiques. Seule une allow-list (assainisseur éprouvé) est défendable pour du HTML riche.
CSP mal configurée. Une politique de sécurité du contenu réduit fortement l'impact, à condition d'être stricte. Les erreurs courantes, décrites conceptuellement :
unsafe-inlinedansscript-src, qui réautorise précisément les scripts injectés.unsafe-eval, qui laisse passer les sinks d'évaluation de chaînes.- Des listes de domaines trop larges (CDN publics, domaines hébergeant du contenu utilisateur ou des points JSONP), depuis lesquels un attaquant peut charger du code légitime détourné.
- L'absence de
object-src 'none'et debase-uri, qui permet de modifier la base des URL relatives. - Une politique en mode
Report-Only, qui ne bloque rien.
HttpOnly et SameSite. HttpOnly empêche JavaScript de lire le cookie de session : le vol de cookie disparaît, mais pas la XSS. Le script peut toujours envoyer des requêtes authentifiées depuis l'origine, puisque le navigateur y joint le cookie. SameSite protège contre les requêtes intersites (CSRF), mais une XSS s'exécute dans le même site : elle n'est pas concernée. En revanche, si les API utilisent un jeton stocké dans localStorage, la XSS peut le lire, ce qui aggrave l'impact.
Self-XSS. Une XSS que seule la victime peut déclencher en collant elle-même du code ou en modifiant son propre profil privé est généralement classée informative. Elle devient recevable si tu la chaînes à un autre défaut qui permet de la déclencher chez autrui (CSRF sur le champ concerné, connexion forcée, cache partagé).
Question de réflexion : une XSS stockée dans un ticket de support visible seulement par le personnel est-elle moins grave qu'une XSS publique ?
Souvent, c'est l'inverse. Le nombre de victimes est réduit, mais elles disposent de privilèges élevés : consultation des données de tous les clients, réinitialisation de comptes, modification de rôles. Un attaquant non authentifié qui atteint un back-office via un formulaire de contact obtient potentiellement un accès administrateur indirect. Cela se traduit en CVSS par PR:N, S:C et des impacts élevés.
Prouver l'impact sans nuire
Preuves acceptées :
alert(document.domain)oualert(window.origin), avec capture d'écran montrant l'URL.- Une modification visible et anodine de la page (titre, bannière de texte) sur ton compte de test.
- Pour démontrer un impact plus élevé : une action effectuée au nom de ton second compte de test (modifier son nom d'affichage, par exemple), ou la lecture d'une donnée de la page appartenant à ce compte de test.
- Pour une chaîne vers la prise de contrôle de compte (ATO) : démonstration sur tes deux comptes uniquement (par exemple, changement de l'adresse e-mail du compte victime de test lorsque la page ne demande pas le mot de passe actuel).
Interdits :
- Toute collecte de cookies, jetons, contenu de page ou frappes clavier d'utilisateurs réels.
- Une XSS stockée laissée en place sur une page publique fréquentée : utilise des contenus privés ou supprime-la immédiatement après la capture.
- Les charges qui se propagent (vers XSS) ou qui affectent la disponibilité.
- Les sondes aveugles qui transmettent autre chose qu'un signal d'exécution.
Évaluer la sévérité
| Scénario | Vecteur CVSS 3.1 | Score |
|---|---|---|
| XSS réfléchie non authentifiée, lien à cliquer, impact limité | AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N | 6.1 (Moyenne) |
| XSS stockée publiée par un utilisateur authentifié, vue par d'autres | AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N | 5.4 (Moyenne) |
| XSS stockée chaînée à une prise de contrôle de compte | AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N | 8.7 (Élevée) |
| XSS aveugle via formulaire de contact public, exécutée dans le back-office | AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N | 9.3 (Critique) |
S:C se justifie parce que le composant vulnérable (l'application serveur) diffère du composant impacté (le navigateur de la victime), conformément aux exemples de la spécification FIRST. N'affirme C:H/I:H que si tu as démontré, sur tes comptes, la chaîne qui y conduit.
Corriger
Code vulnérable (Node.js / Express) : la valeur est concaténée dans le HTML.
// VULNÉRABLE
app.get("/search", (req, res) => {
const q = req.query.q ?? "";
res.send("<h1>Résultats pour " + q + "</h1>");
});Code corrigé : un moteur de templates auto-échappant encode selon le contexte.
// CORRIGÉ (gabarit search.njk : <h1>Résultats pour {{ q }}</h1>)
const nunjucks = require("nunjucks");
nunjucks.configure("views", { autoescape: true, express: app });
app.get("/search", (req, res) => {
res.render("search.njk", { q: String(req.query.q ?? "") });
});Côté client, pour une XSS DOM :
// VULNÉRABLE
results.innerHTML = "Recherche : " + decodeURIComponent(location.hash.slice(1));
// CORRIGÉ : texte pur
results.textContent = "Recherche : " + decodeURIComponent(location.hash.slice(1));
// CORRIGÉ : HTML riche indispensable, assaini par allow-list
import DOMPurify from "dompurify";
preview.innerHTML = DOMPurify.sanitize(userMarkdownHtml);Défense en profondeur :
- Templates auto-échappants partout, et revue systématique des échappatoires (
|safe,dangerouslySetInnerHTML,v-html). - DOMPurify pour tout HTML fourni par l'utilisateur, avec une configuration restrictive.
- Validation des URL : n'accepter que
https:(etmailto:si nécessaire) pour les liens construits à partir de données. - Trusted Types (
require-trusted-types-for 'script') : les sinks DOM refusent les chaînes brutes, ce qui élimine structurellement la plupart des XSS DOM. - CSP stricte à nonce :
script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none', avec un nonce aléatoire différent à chaque réponse. - Cookies
HttpOnly,Secure,SameSite=Laxau minimum, et pas de jetons de session danslocalStorage. - Réauthentification pour les actions critiques (changement d'e-mail ou de mot de passe), qui casse les chaînes vers l'ATO.
Erreurs qui font rejeter un rapport
- Soumettre une self-XSS sans scénario de déclenchement chez un tiers.
- Utiliser
alert(1)sur un domaine bac à sable ou hors périmètre sans vérifier l'origine. - Exagérer l'impact (« vol de session ») alors que les cookies sont
HttpOnlyet que rien n'a été démontré. - Confondre injection HTML et XSS : une balise affichée mais aucun script exécuté relève d'une sévérité bien moindre.
- Laisser une charge stockée active sur une page publique, ou collecter des données de vrais utilisateurs.
- Ignorer la CSP : si elle bloque l'exécution, l'impact doit être réévalué ou le contournement expliqué conceptuellement.
Checklist du hunter
- Périmètre et règles relus, deux comptes de test prêts.
- Marqueur unique injecté et retrouvé dans la réponse et dans le DOM.
- Contexte identifié pour chaque occurrence (HTML, attribut, JS, URL).
- Caractères de structure testés : encodés ou non ?
- Sources et sinks DOM vérifiés dans le JavaScript client.
- Preuve minimale :
alert(document.domain)ou modification visible, rien de plus. - CSP,
HttpOnly,SameSiteet stockage des jetons analysés pour l'impact. - Charge stockée supprimée après capture, rapport avec contexte, impact et correction.
Pour aller plus loin
- OWASP Cheat Sheet Series — Cross Site Scripting Prevention Cheat Sheet.
- OWASP Cheat Sheet Series — DOM based XSS Prevention Cheat Sheet.
- OWASP WSTG v4.2 — WSTG-INPV-01, WSTG-INPV-02 et WSTG-CLNT-01.
- PortSwigger Web Security Academy — Cross-site scripting, DOM-based vulnerabilities et Content Security Policy.
- MITRE CWE-79 — Improper Neutralization of Input During Web Page Generation.
- W3C — Content Security Policy Level 3 et Trusted Types.