Comment un XSS arrive via JSON
L’essentiel : JSON ne s'exécute jamais de lui-même. Le XSS survient au moment où vous le traitez comme du HTML — concaténation de chaînes côté serveur, innerHTML frontend, ou intégration directe de JSON dans une balise script.

L'essentiel : JSON ne s'exécute jamais de lui-même — fermez d'abord les trois chemins de sortie : échappez côté serveur selon le contexte, utilisez textContent au lieu de innerHTML, et remplacez < par \u003c lors de l'intégration de JSON.
{"name": "<img src=x onerror=alert(1)>"} est une donnée valide. Sa dangerosité dépend entièrement de l'endroit où vous la mettez.
Entrée un : construction de chaînes côté serveur
res.send(`<div>Hello, ${data.name}</div>`);
Le navigateur parse les balises à l'intérieur de data.name. JSON ici n'est que le conteneur ; la faute est d'échapper pour le mauvais contexte.
Correction : utilisez l'échappement par défaut d'un moteur de gabarit (<%= %> en EJS, {{ }} en Vue et Django). Évitez <%- %>, v-html et |safe pour tout ce qui est contrôlé par l'utilisateur.
Entrée deux : innerHTML
el.innerHTML = user.bio; // parses markup
el.textContent = user.bio; // inserts text
Le {user.bio} de React et le {{ user.bio }} de Vue créent des nœuds de texte et sont sûrs par défaut. dangerouslySetInnerHTML et v-html sont des désactivations explicites.
Si vous avez vraiment besoin de texte enrichi, assainissez avec une liste blanche (DOMPurify et similaires) plutôt que de faire confiance au backend.
Entrée trois : intégration dans une balise script
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
Un </script> dans les données ferme le bloc trop tôt et tout ce qui suit s'exécute comme un nouveau script. C'est le cas le plus souvent raté.
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
Toujours du JSON valide, identique après parsing, incapable de fermer la balise. Next.js et Nuxt font de même pour __NEXT_DATA__ et __NUXT__.
Deux voisins à connaître
Détournement JSON — les anciens navigateurs exécutaient du JSON chargé via <script src> ; redéfinir Array faisait fuiter le contenu. Les navigateurs modernes l'ont corrigé, et Content-Type: application/json plus X-Content-Type-Options: nosniff ferment le reste.
Restes de JSONP — un nom de callback contrôlé par l'utilisateur est de l'exécution de code arbitraire par conception. Ne l'utilisez pas dans les nouveaux systèmes.
Liste de vérification
- Chaque endroit où un champ JSON atteint du HTML est échappé pour son contexte (HTML, attribut, JS, URL)
- Aucun
innerHTML,v-htmloudangerouslySetInnerHTMLne consomme des chaînes d'API - Le JSON intégré échappe
<,>et& - Le texte enrichi passe par un assainissement en liste blanche, pas par un filtrage en liste noire
- Les réponses portent
Content-Type: application/jsonetnosniff
FAQ
Pourquoi du JSON valide peut-il quand même déclencher un XSS ?
Le JSON n'est que du texte et ne s'exécute jamais ; être valide signifie seulement qu'il parse. Le danger apparaît quand il est écrit dans un contexte HTML ou script : le navigateur parse les balises, ou un </script> ferme le bloc trop tôt et le reste s'exécute comme du code. La faute est l'absence d'échappement contextuel à la sortie, pas le format de données.
Faut-il utiliser textContent ou innerHTML pour rendre des champs d'API ?
Préférez textContent : il insère la valeur comme texte brut, donc les balises sont affichées littéralement. innerHTML parse le balisage, donc un champ d'API contenant <img onerror=...> s'exécute. Le {user.bio} de React et le {{ user.bio }} de Vue créent des nœuds de texte par défaut ; dangerouslySetInnerHTML et v-html sont des désactivations explicites.
Comment échapper du JSON intégré dans une balise <script> ?
Oui. Un </script> dans les données ferme le bloc trop tôt et tout ce qui suit s'exécute comme un nouveau script. Après sérialisation, remplacez <, > et & par \u003c, \u003e et \u0026 : le résultat est toujours du JSON valide, parse identiquement, et ne peut plus fermer la balise.