SymfonyCon Warsaw 2026 vient de publier son programme, et une intervention a retenu mon attention : « Why AI Output Is the New XSS », par Ramona Schwering (mittwald). Le pitch tient en une phrase : les développeurs se méfient instinctivement de l'input utilisateur, mais traitent la sortie d'un LLM comme un contenu de confiance — alors que rien, structurellement, ne la distingue d'un input non fiable. C'est exactement la classe de bug que l'OWASP a rangée sous LLM05 (Improper Output Handling), et c'est un angle mort que je trouve sous-discuté par rapport à l'injection de prompt, qui monopolise l'attention côté sécurité IA.
Le glissement mental
Un input HTML depuis un <textarea>, personne ne l'echo tel quel. C'est le b.a.-ba de l'OWASP top 10, martelé depuis quinze ans. Mais dès qu'un LLM produit du texte, quelque chose change dans la tête du développeur : ce n'est plus « une donnée qui vient d'ailleurs », c'est « une réponse », presque un contenu généré par le système lui-même. Le pipeline mental passe de untrusted → sanitize → render à generate → render, sans étape intermédiaire.
Le problème, c'est que la sortie d'un modèle n'est pas plus fiable que l'input qui l'a produite — elle est même plus difficile à borner, puisqu'un LLM peut halluciner ou être manipulé par injection de prompt pour émettre exactement le balisage qu'un attaquant veut voir dans la page. Un payload <img src=x onerror=fetch(...)> glissé dans un document indexé par un pipeline RAG, ou dans la réponse d'un outil appelé via MCP, ressort du modèle avec la même autorité apparente qu'une phrase légitime. Rien dans le flux ne le signale comme suspect.
Pourquoi ce blog vit avec cette règle au quotidien
Ce cas n'est pas théorique pour moi : sur ce blog, le champ article.text est rendu en HTML brut, sans échappement — un choix assumé pour pouvoir glisser des SVG, des tables ou des petits widgets JS directement dans un article (celui que vous lisez en contient un plus bas). Ça ne fonctionne que sous une invariante stricte : seul l'auteur écrit dans cette colonne, via une commande SQL que je contrôle entièrement. Aucune route, aucun formulaire, aucune donnée visiteur ne doit jamais atteindre ce champ.
Le jour où j'automatiserais la génération d'articles avec un LLM branché en écriture directe sur cette colonne — sans passage par une étape de revue humaine qui échapperait ou validerait le balisage produit — j'aurais recréé exactement le scénario que décrit Schwering : une source qui se comporte comme du contenu de confiance alors qu'elle peut, sous certaines conditions (prompt injection via une source externe consultée pendant la génération, hallucination d'un tag), produire n'importe quel balisage arbitraire. La règle qui protège ce blog n'est pas technique, elle est procédurale : la sortie du modèle ne devient « fiable » qu'après une relecture humaine qui joue le rôle de la sanitization.
Ce que « traiter la sortie IA comme un input » veut dire concrètement
| Contexte | Erreur fréquente | Traitement correct |
|---|---|---|
| Chat widget (React/Vue) | dangerouslySetInnerHTML / v-html sur la réponse brute du modèle | Rendu Markdown via un parser qui échappe le HTML embarqué, pas un passage direct au DOM |
| Résumé RAG affiché à l'utilisateur | Contenu du document source considéré « déjà nettoyé » car indexé en interne | Le document source est un input externe — même règle d'échappement que n'importe quel contenu tiers |
| Sortie d'un outil MCP réinjectée dans une UI | Résultat d'outil traité comme donnée système car il transite par le protocole | Le protocole ne garantit rien sur le contenu — seule la source de la donnée compte |
| Génération de contenu éditorial | Publication automatique sans étape de revue humaine | Revue humaine obligatoire avant toute écriture dans un champ rendu sans échappement |
Le deuxième champ du widget ci-dessus n'exécute rien — je ne vais pas construire un déclencheur XSS live sur mon propre blog — mais il montre le point exact où la décision se joue : entre htmlspecialchars() côté serveur (l'équivalent du helper $h() utilisé partout ailleurs sur ce site) et un echo nu, il n'y a qu'une ligne de différence, et c'est cette ligne que l'habitude de faire confiance au LLM fait sauter.
Mon désaccord partiel avec l'angle « nouvelle menace »
Je partage le diagnostic de Schwering, mais pas complètement le cadrage « nouvelle » menace. Ce n'est pas un nouveau type de vulnérabilité — c'est de la XSS classique, avec une source de plus dans la liste des inputs non fiables. Le vrai problème n'est pas technique, il est cognitif : on a mis quinze ans à faire rentrer « toute donnée externe est hostile jusqu'à preuve du contraire » dans les réflexes de l'industrie, et l'arrivée des LLM a créé une catégorie de données qui échappe silencieusement à cette règle parce qu'elle a l'air générée plutôt que soumise. Le fix n'est donc pas une nouvelle discipline de sécurité spécifique à l'IA — c'est l'application, sans exception, de la discipline d'échappement qui existe déjà, à une source qu'on avait implicitement exemptée.
FAQ
La sortie d'un LLM peut-elle vraiment contenir du HTML malveillant sans intervention d'un attaquant ?
Oui, par hallucination pure — un modèle peut générer une balise <script> ou un attribut onerror sans qu'aucune injection de prompt n'ait eu lieu, simplement parce qu'il a appris ce motif dans son corpus d'entraînement.
Échapper la sortie du modèle casse-t-il le rendu Markdown enrichi (gras, liens, code) ?
Non, si le pipeline est correct : on échappe le HTML brut, puis on fait passer le résultat dans un parser Markdown dédié qui génère lui-même du HTML propre — jamais un rendu direct du texte du modèle comme s'il était déjà du HTML de confiance.
Ce risque concerne-t-il aussi les résultats d'un outil MCP, pas seulement les réponses de chat ?
Oui — tout ce qui finit affiché dans une UI après être passé par un LLM ou un outil qu'il appelle doit être traité comme un input externe, y compris les résultats d'outils MCP, cf. les contrôles détaillés dans sécuriser un serveur MCP.