Du code dans un message : le XSS
Temps 2 · séance 1 · ~40 min
- Distinguer le texte qu'une page affiche du HTML qu'un navigateur exécute.
- Rejouer une attaque qui fait agir le navigateur d'une victime, connecté comme l'attaquant.
- Expliquer ce qu'une charge piégée obtient avec la session de la victime, et ce qu'elle n'obtient pas.
- Corriger la faille en affichant chaque message comme du texte.
- Nettoyer le HTML côté serveur le jour où vous voulez vraiment du HTML.
1. Un ver sur MySpace
Le 4 octobre 2005, Samy Kamkar glisse du JavaScript dans son profil MySpace, déguisé pour passer les filtres du site. Chaque personne qui visite le profil recopie ce code sur le sien sans le savoir et ajoute Samy à ses amis. En une vingtaine d'heures, plus d'un million de comptes sont touchés et MySpace doit couper le site.
Le ver n'effaçait rien et ne volait rien. Il ajoutait une phrase et une demande d'ami à chaque profil, sans l'accord de personne, et sa seule propagation a suffi à faire tomber le site. La leçon reste entière : tout texte écrit par un utilisateur doit être traité comme du texte, jamais comme du code, au moment de l'afficher. Le Tamagotchi affiche aujourd'hui les messages du livre d'or sans cette précaution.
2. Du texte ou du code
Ouvrez resources/js/pages/enclosures/Show.vue, la page d'un enclos. Chaque message du livre d'or s'affiche par cette ligne :
<div v-html="message.body" />
v-html est une directive de Vue qui insère son contenu comme du HTML. Le navigateur lit la chaîne, y reconnaît des balises et construit des éléments. Un <b> dans le message devient du gras à l'écran. Le formulaire du livre d'or l'annonce lui-même, sous la zone de saisie :
<p class="text-sm text-muted-foreground">
Le gras est permis : <b>comme ceci</b>.
</p>
Vous connaissez l'autre écriture depuis vos premières pages Vue. Les doubles accolades affichent une valeur comme du texte :
<p>{{ message.body }}</p>
La différence tient en un mot. {{ }} insère la valeur comme du texte : Vue la pose dans la page caractère par caractère, sans y créer la moindre balise. v-html, lui, insère la valeur comme du HTML : il la confie au navigateur, qui y reconnaît des balises et construit des éléments.
La page reçoit toujours la même chaîne de caractères, <b>Bonjour Alice</b>. Ce que les deux écritures en font diffère. Avec les accolades, Vue l'insère comme du texte : aucun élément <b> n'est créé, et la page affiche <b>Bonjour Alice</b>, balises comprises. Avec v-html, Vue l'insère comme du HTML : le navigateur crée un vrai élément <b>, et la page affiche Bonjour Alice en gras.

Un message en gras est anodin. Le problème est que v-html ne s'arrête pas au gras. Il insère la chaîne comme du HTML sans la nettoyer, et le navigateur exécute alors le JavaScript d'un attribut d'événement, comme onerror. Faire exécuter son JavaScript dans le navigateur d'un autre utilisateur, par du contenu affiché sans précaution, s'appelle une injection de script côté client, ou XSS. Le message reste en base entre deux visites, donc la faille est stockée : chaque lecteur la subit à son tour.
3. L'attaque : un message qui agit
Mallory connaît l'adresse de l'enclos d'Alice depuis le chapitre 2.01 : tamagotchi.test/enclos/1. Elle y laisse un message, mais au lieu de texte elle écrit une balise piégée. Une remarque d'abord : une balise <script> insérée par v-html ne s'exécute pas. Pour insérer du HTML, v-html utilise la propriété innerHTML du navigateur, et un <script> ajouté par innerHTML ne se lance jamais. Il faut une balise qui porte un attribut d'événement. Une image dont la source échoue déclenche son attribut onerror :
<img src=x onerror="/* du JavaScript ici */">
src=x ne pointe vers aucune image. Le navigateur essaie de la charger, échoue, et lance le JavaScript de onerror. Pour une première preuve, Mallory fait changer la page d'Alice sous ses yeux. Elle poste ce message :
<img src=x onerror="var d=document.createElement('div');d.textContent='XSS : Mallory contrôle cette page';d.setAttribute('style','background:#E2231A;color:#fff;padding:16px;font-size:26px;font-weight:900');document.body.prepend(d)">
Le message s'exécute chez chaque personne qui ouvre l'enclos, Mallory comprise juste après l'envoi. Quand Alice ouvre son propre enclos, le bandeau rouge apparaît en haut de sa page, cette fois dans sa session à elle. Elle n'a rien cliqué. Le message de Mallory, lui, s'affiche comme une image cassée, puisque src=x n'a rien chargé.

Un bandeau n'est qu'une démonstration. Le même onerror peut lancer une requête au nom d'Alice. Le navigateur d'Alice porte sa session, donc toute requête partie de sa page agit comme si Alice l'avait voulue. Comme au chapitre 2.02, le script lit d'abord le jeton CSRF dans le cookie XSRF-TOKEN, puis l'envoie dans l'en-tête X-XSRF-TOKEN. Mallory remplace le bandeau par cette requête, qui change la devise d'Alice :
<img src=x onerror="var t=decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]);fetch('/enclos',{method:'PATCH',headers:{'Content-Type':'application/json','X-XSRF-TOKEN':t},body:JSON.stringify({motto:'Mallory est passée par ici'})})">
Dès qu'Alice ouvre son enclos, son navigateur envoie cette requête. Relevée pendant la visite, elle se lit ainsi :
PATCH …/enclos
X-XSRF-TOKEN: …
{"motto":"Mallory est passée par ici"}
La devise d'Alice change en base. Alice n'a rien demandé. La requête part de sa page, avec sa session, et le serveur l'exécute comme n'importe quelle action d'Alice.

Ce que la charge peut, et ne peut pas
La charge agit avec la session d'Alice, donc elle peut faire tout ce que le jeu autorise à Alice : changer sa devise, renommer ou relâcher ses créatures, écrire un message en son nom. Elle le fait sans qu'Alice clique.
Sur votre copie, ouverte à http://tamagotchi.test, Laravel demande un jeton pour chaque requête qui écrit. Un jeton CSRF est une valeur secrète que le serveur attend d'une requête qui modifie des données, pour vérifier qu'elle part bien d'une page du site. Laravel le range dans un cookie XSRF-TOKEN, et la page le renvoie dans l'en-tête X-XSRF-TOKEN. Pour que la page y arrive, ce cookie est lisible par le JavaScript, et le composant <Form> d'Inertia le lit à chaque envoi. Le script de Mallory le lit donc lui aussi. Le jeton CSRF ne protège pas contre un script qui s'exécute déjà sur la page. Son rôle exact est détaillé au chapitre 2.11.
Une protection tient quand même. Le cookie de session est marqué HttpOnly : le JavaScript ne peut pas le lire. La charge ne peut donc pas voler la session d'Alice pour la rejouer ailleurs. Mais elle n'en a pas besoin, car le cookie part tout seul avec chaque requête de la page d'Alice.
Attention
Ce que la charge ne fait pas ici : lire le cookie de session, car il est HttpOnly. Ce qu'elle fait : toute action que la session ouverte d'Alice autorise sur le site, en lisant le jeton CSRF dans un cookie que la page doit elle-même pouvoir lire. Une faille XSS vaut les droits de celui qui lit le message.
4. Refaites l'attaque sur votre copie
On n'attaque que sa propre copie, sur sa propre machine. Pour tenir les deux comptes à la fois, gardez Mallory dans une fenêtre normale et ouvrez Alice dans une fenêtre de navigation privée, comme au chapitre 2.01. Deux fenêtres normales du même navigateur partagent la session : la fenêtre privée en ouvre une seconde, séparée.
- Dans la fenêtre normale, connecté comme Mallory, ouvrez l'enclos d'Alice,
tamagotchi.test/enclos/1. - Dans le livre d'or, collez le message à bandeau de la section 3, puis envoyez.
- Dans la fenêtre privée, connectez-vous comme Alice et ouvrez
tamagotchi.test/enclos/1. Le bandeau apparaît en haut de la page. - Revenez dans la fenêtre de Mallory. Remplacez le message par celui qui change la devise, puis envoyez. Dans la fenêtre d'Alice, ouvrez l'onglet Réseau, puis rechargez l'enclos : la requête
PATCH /enclospart toute seule. Rechargez une seconde fois : la devise d'Alice, en haut de son enclos, porte maintenant le texte de Mallory.
La deuxième lecture compte. Le onerror s'exécute après l'affichage de la page, donc la devise ne change qu'ensuite, en base. La page déjà ouverte garde l'ancienne devise jusqu'au rechargement suivant.
L'onglet Éléments montre le message inséré dans la page : la balise <img> y est un vrai élément, pas du texte. L'onglet Réseau montre la requête que le onerror a lancée.
5. La correction : afficher du texte
La faille vient d'un seul choix : v-html traite le message comme du HTML. La bonne pratique tient en une règle. Un message est écrit par un visiteur, donc c'est du texte, et on l'affiche comme du texte.
Cherchez dans enclosures/Show.vue la ligne qui affiche message.body, et demandez-vous quelle écriture affiche une valeur comme du texte. Pensez aussi à l'invitation « Le gras est permis » sous le formulaire : elle n'a plus lieu d'être. Prenez dix minutes avant d'ouvrir la correction.
Après la correction, la page reçoit toujours la même chaîne, mais Vue l'insère comme du texte. Pour <b>Bonjour Alice</b>, aucun élément <b> n'est créé, et les balises s'affichent en toutes lettres. Le message piégé de Mallory s'affiche en entier, inoffensif.

6. La preuve
Le test de la faille lit le fichier de la page et refuse d'y trouver v-html. Il est clair : tant que v-html reste dans enclosures/Show.vue, il échoue. Lancez-le avant la correction :
php artisan test --filter=Flaw05
FAIL Tests\Feature\Flaws\Flaw05XssTest
⨯ the guest book does not display messages as HTML
Expecting '<script setup>import { Form…yle>' not to contain 'v-html'.
Tests: 1 failed (1 assertions)
Faites la correction, relancez la même commande :
PASS Tests\Feature\Flaws\Flaw05XssTest
✓ the guest book does not display messages as HTML
Tests: 1 passed (1 assertions)
Sur le tableau de bord complet, le nombre de tests rouges passe de dix-sept à seize. Rejouez enfin l'attaque, connecté comme Mallory puis comme Alice : le bandeau n'apparaît plus, et la requête PATCH /enclos ne part plus de la page d'Alice. Le message piégé est devenu du texte.
7. Quand on veut vraiment du HTML
Au chapitre 1.15, votre portfolio affiche le contenu de vos projets avec v-html, et un encart annonçait que le nettoyage viendrait au Tamagotchi. Le voici. Afficher du HTML par v-html reste acceptable dans votre portfolio pour une seule raison : un seul auteur écrit ce contenu, l'administrateur connecté, c'est-à-dire vous. Le HTML affiché est le vôtre.
Le jour où un visiteur peut écrire du HTML montré à d'autres, un commentaire ou un message, la règle change. Si vous voulez garder le gras et les titres de ce HTML, vous ne pouvez plus l'afficher comme du texte. Vous le nettoyez côté serveur avant de l'enregistrer : vous gardez une liste blanche de balises sûres et vous retirez tout le reste, les balises de script et les attributs d'événement compris.
Laravel ne fournit pas de nettoyeur. Un paquet reconnu le fait, symfony/html-sanitizer. Il prend une liste de balises permises et rend une chaîne sûre :
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
$config = (new HtmlSanitizerConfig())->allowElement('b')->allowElement('p');
$sanitizer = new HtmlSanitizer($config);
echo $sanitizer->sanitize('<b>Bonjour</b> <img src=x onerror="alert(1)">');
<b>Bonjour</b>
Le gras passe, l'image piégée disparaît. Cet exemple suppose le paquet déjà installé dans un projet. Le Tamagotchi ne le fournit pas et n'en a pas besoin, puisque sa correction affiche le message comme du texte. Dans votre portfolio non plus, rien n'est à changer aujourd'hui : seul l'administrateur écrit ce contenu, aucun visiteur ne peut y écrire. Ce nettoyage devient obligatoire dès qu'un formulaire ouvert au public accepte du HTML.
8. La règle
Recopiez-la dans votre carnet des failles, avec l'endroit de votre portfolio où elle s'applique, par exemple le contenu riche d'un projet affiché par v-html.
Tout contenu écrit par un utilisateur s'affiche comme du texte. Si vous voulez vraiment du HTML, nettoyez-le côté serveur avant de l'enregistrer.
Le chapitre 2.06, « Une recherche qui lit toute la base : l'injection SQL », montre une faille voisine, côté serveur cette fois.
À retenir
v-htmlinsère une chaîne comme du HTML, et un attribut d'événement peut alors exécuter du JavaScript.{{ }}insère la valeur comme du texte, sans créer de balise.- Une balise
<script>insérée parinnerHTML, le mécanisme dev-html, ne s'exécute pas, mais un attribut commeonerrors'exécute. - Un message piégé agit avec la session de celui qui l'ouvre, donc avec ses droits sur le site.
- Le jeton CSRF arrête une requête envoyée depuis un autre site, pas un script déjà en train de tourner sur le vôtre.
- Pour afficher du HTML voulu, nettoyez-le côté serveur avec une liste blanche de balises.
Pour aller plus loin
- « Samy (computer worm) », Wikipédia : https://en.wikipedia.org/wiki/Samy_(computer_worm)
- « Web 2.0 worm knocks out MySpace », The Register, 17 octobre 2005 : https://www.theregister.com/2005/10/17/web20_worm_knocks_out_myspaces/