Les failles XSS : comprendre et s'en protéger
La faille XSS (Cross-Site Scripting) figure depuis des années parmi les vulnérabilités web les plus répandues. Son principe : un attaquant parvient à injecter du code JavaScript qui s’exécutera dans le navigateur d’autres utilisateurs. Les conséquences vont du vol de session à la modification silencieuse d’une page. Ce guide explique les différents types de failles XSS, montre comment elles s’exploitent, et surtout comment s’en protéger durablement.
Le principe d’une faille XSS
Une application web devient vulnérable au XSS lorsqu’elle affiche des données fournies par l’utilisateur sans les traiter correctement. Le navigateur ne fait pas la différence entre le code écrit par le développeur et le code injecté : il exécute tout ce qui ressemble à un script dans la page.
Prenons un champ de recherche qui réaffiche le terme saisi :
<?php
// Vulnérable : la saisie est affichée telle quelle
echo "Vous avez cherché : " . $_GET['q'];
Si un utilisateur visite ?q=<script>alert('XSS')</script>, le navigateur exécute le script. Ici c’est une simple alerte, mais le même mécanisme permet d’exécuter n’importe quel code : lire les cookies, envoyer des données vers un serveur tiers, afficher un faux formulaire de connexion.
Le cœur du problème : une donnée n’est jamais « juste du texte » tant qu’elle n’a pas été échappée. Toute entrée utilisateur doit être considérée comme potentiellement hostile.
Les trois types de XSS
XSS réfléchi (reflected)
La charge malveillante est incluse dans la requête (souvent une URL) et immédiatement renvoyée dans la réponse, sans être stockée. L’attaque suppose que la victime clique sur un lien piégé. C’est le cas de l’exemple ci-dessus : le paramètre q est « réfléchi » dans la page.
XSS stocké (stored)
Le plus dangereux. La charge est enregistrée sur le serveur (base de données, fichier) puis servie à tous les visiteurs. Un commentaire de blog contenant un script, sauvegardé sans traitement, se déclenchera pour chaque personne qui affiche la page. Un seul envoi peut toucher des milliers d’utilisateurs.
// Côté serveur : on stocke le commentaire brut
db.comments.insert({ texte: req.body.comment }); // dangereux
// Plus tard, on l'affiche sans échappement
element.innerHTML = comment.texte; // exécute tout script présent
XSS basé sur le DOM (DOM-based)
Ici, l’injection ne passe pas par le serveur : c’est du JavaScript côté client qui écrit une donnée non fiable dans la page. Le serveur peut être totalement sain ; la faille vit entièrement dans le navigateur.
// Vulnérable : location.hash est contrôlé par l'utilisateur
document.getElementById("zone").innerHTML = location.hash.slice(1);
Une URL comme page.html#<img src=x onerror=alert(1)> déclenche le code. Ce type est en forte progression avec les applications à rendu client (React, Vue, etc.), même si ces frameworks offrent de bonnes protections par défaut.
La défense n°1 : l’échappement à l’affichage
La règle fondamentale est d’échapper les données au moment où on les insère dans une page. Échapper signifie transformer les caractères spéciaux du HTML (<, >, &, ", ') en leurs entités, pour qu’ils soient affichés comme du texte et non interprétés comme du code.
En PHP, htmlspecialchars() fait ce travail :
<?php
// Sécurisé : les caractères spéciaux sont neutralisés
echo "Vous avez cherché : " . htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8');
Désormais, <script> devient <script> et s’affiche littéralement au lieu de s’exécuter. L’option ENT_QUOTES échappe aussi les guillemets simples et doubles, indispensable si la donnée atterrit dans un attribut HTML.
Le point crucial : l’échappement dépend du contexte d’insertion. Une donnée placée dans du HTML, dans un attribut, dans du JavaScript ou dans une URL ne s’échappe pas de la même manière. Un échappement HTML ne protège pas une valeur injectée dans du code JS.
Le rôle des frameworks modernes
La bonne nouvelle : les frameworks récents échappent par défaut. React, par exemple, considère toute valeur insérée en JSX comme du texte.
// Sécurisé : React échappe automatiquement
function Commentaire({ texte }) {
return <p>{texte}</p>; // même <script> s'affiche comme du texte
}
Le danger réapparaît dès qu’on court-circuite cette protection. En React, c’est la propriété au nom volontairement effrayant dangerouslySetInnerHTML ; en Vue, la directive v-html ; en JavaScript pur, innerHTML. Chaque fois que vous injectez du HTML brut, vous reprenez la responsabilité de la sécurité.
// À manier avec une extrême prudence
<div dangerouslySetInnerHTML={{ __html: contenuUtilisateur }} />
Règle simple : préférez toujours l’insertion de texte (textContent plutôt qu’innerHTML, l’interpolation JSX plutôt que dangerouslySetInnerHTML). Vous n’aurez recours au HTML brut que dans de rares cas justifiés — et alors, il devra être assaini au préalable.
La sanitization : quand il faut autoriser du HTML
Certaines fonctionnalités exigent d’accepter du HTML de l’utilisateur : un éditeur de texte enrichi, un système de commentaires formatés. Interdire tout HTML n’est plus possible. La solution est la sanitization : passer le contenu dans une bibliothèque qui ne conserve qu’une liste blanche de balises et d’attributs sûrs, et supprime le reste.
L’outil de référence côté navigateur est DOMPurify :
import DOMPurify from "dompurify";
const sale = '<img src=x onerror=alert(1)><b>Bonjour</b>';
const propre = DOMPurify.sanitize(sale);
// Résultat : <b>Bonjour</b> — l'attribut piégé a disparu
element.innerHTML = propre;
DOMPurify laisse passer la mise en forme légitime (<b>, <a>, <p>) mais retire les gestionnaires d’événements (onerror, onclick) et les balises dangereuses (<script>). Ne réinventez jamais ce genre de filtre à la main avec des expressions régulières : les contournements sont innombrables, et les listes noires artisanales laissent toujours passer un cas.
La CSP : une seconde barrière
La Content Security Policy (CSP) est un en-tête HTTP qui indique au navigateur quelles sources de scripts sont autorisées. C’est une défense en profondeur : même si une injection réussit, la CSP peut empêcher le script malveillant de s’exécuter.
Content-Security-Policy: default-src 'self'; script-src 'self'
Cette politique n’autorise l’exécution de scripts que depuis votre propre domaine. Un <script> injecté en inline, ou chargé depuis un domaine tiers, sera bloqué par le navigateur. La CSP ne remplace pas l’échappement — c’est un filet de sécurité, pas la protection principale — mais elle réduit fortement l’impact d’une faille passée entre les mailles. Évitez à tout prix la directive 'unsafe-inline', qui rouvre la porte au XSS inline.
Défense en profondeur : aucune couche n’est infaillible seule. Échappement contextuel plus sanitization pour le HTML autorisé plus CSP forment un ensemble bien plus robuste que n’importe quelle mesure isolée.
Bonnes pratiques à retenir
Pour verrouiller une application contre le XSS, quelques réflexes suffisent à couvrir l’essentiel :
- Ne jamais faire confiance à une entrée utilisateur, quelle que soit sa source (formulaire, URL, en-tête, API).
- Échapper à l’affichage, selon le contexte — HTML, attribut, JS ou URL n’ont pas les mêmes règles.
- Privilégier les insertions de texte (
textContent, interpolation) plutôt que le HTML brut. - Assainir avec une bibliothèque éprouvée (DOMPurify) quand du HTML doit être autorisé.
- Déployer une CSP stricte comme seconde ligne de défense.
- Marquer les cookies de session avec l’attribut
HttpOnly, pour qu’ils restent inaccessibles au JavaScript.
La sécurité n’est pas une fonctionnalité qu’on ajoute à la fin : elle se pense à chaque endroit où une donnée entre et sort. Si vous consommez des données via une API, notre guide des API REST rappelle les bons principes de validation côté serveur, qui vont de pair avec la protection XSS côté client.
Comprendre le XSS, c’est intégrer un principe simple mais exigeant : une donnée reste hostile jusqu’à preuve du contraire. Avec l’échappement, la sanitization et la CSP, vous disposez d’un arsenal complet pour livrer des applications web sûres.