système : OPÉRATIONNEL
← retour à tous les hacks
INDIRECT INJECTION MEDIUM

XSS Discourse AI (CVE-2026-27740) : quand la sortie d'un LLM est traitée comme du HTML de confiance

Un message signalé, un modérateur IA, un appel à htmlSafe. Le plugin Discourse AI traitait la sortie LLM comme du markup de confiance, transformant une prompt injection indirecte en XSS côté staff. Publié le 19 mars 2026.

2026-05-26 // 7 min affects: discourse-ai, discourse < 2026.3.0-latest.1, discourse < 2026.2.1, discourse < 2026.1.2

De quoi s’agit-il ?

Le 19 mars 2026, l’équipe Discourse a publié l’advisory GHSA-95hc-42c6-wvvr et obtenu l’identifiant CVE-2026-27740 — une faille XSS stockée dans le plugin Discourse AI, déclenchée via une prompt injection indirecte d’un modérateur LLM. La vulnérabilité a été ajoutée à la NVD le même jour avec un score CVSS 4.0 de 5,1 (MEDIUM) ; elle est corrigée dans Discourse 2026.3.0-latest.1, 2026.2.1 et 2026.1.2.

Le scénario est court et didactique : une automatisation IA de triage lit un message signalé, demande à un LLM d’en résumer la raison, puis affiche ce résumé dans la Review Queue côté staff. Le rendu appelle htmlSafe sur la réponse du LLM. Un attaquant rédige un message conçu pour manipuler le modèle afin qu’il renvoie une balise <script> dans sa “raison”, et le payload s’exécute dès qu’un modérateur ouvre la file. C’est exactement OWASP LLM05 — Improper Output Handling — observé en production (OWASP LLM Top 10).

Comment ça marche

Trois acteurs sont en jeu : l’attaquant qui rédige le message, le job de triage IA qui appelle un LLM, et le modérateur qui ouvre la Review Queue.

[Message de l'attaquant]
   │ contient du contenu conçu pour manipuler le champ "raison" du LLM

[Automatisation Discourse AI triage]
   │ llm_triage.rb — transmet le message au LLM configuré

[Réponse du LLM]
   │ contient du markup contrôlé par l'attaquant, ex. un payload de type script

[Template I18n "discourse_automation.scriptables.llm_triage.flagged_post"]
   │ interpole llm_response et automation_name en `htmlSafe`

[UI Review Queue dans le navigateur du staff]
   │ le payload s'exécute dans une session admin authentifiée

[Vol de session / actions admin / altération de configuration]

La cause racine est documentée dans le commit de correction 44b84439. Avant le patch, plugins/discourse-ai/lib/automation/llm_triage.rb injectait la réponse du LLM directement dans l’appel de traduction :

I18n.t(
  "discourse_automation.scriptables.llm_triage.flagged_post",
  base_path: Discourse.base_path,
  llm_response: result,                 # sortie LLM brute
  automation_name: automation&.name.to_s
)

Le template flagged_post rendait ces valeurs avec htmlSafe, qui est l’opt-out explicite de Rails à l’échappement HTML. Le patch encapsule les deux champs avec ERB::Util.html_escape :

llm_response: ERB::Util.html_escape(result),
automation_name: ERB::Util.html_escape(automation&.name.to_s),

Des correctifs équivalents sont déployés dans plugins/discourse-ai/lib/personas/tools/flag_post.rb et plugins/discourse-ai/lib/agents/tools/flag_post.rb. Aucun payload exploitable n’est reproduit ici ; le diff public reste la référence canonique pour les défenseurs.

L’interaction utilisateur (UI:P dans le vecteur CVSS) correspond au modérateur qui ouvre la file — ce pour quoi il est payé. C’est ce qui rend ce CVSS 5,1 “moyen” plus lourd qu’il n’en a l’air en pratique : le déclencheur fait partie du workflow normal de modération.

Pourquoi ça compte

Deux schémas font de cette CVE un cas à étudier au-delà de Discourse.

Le premier est l’erreur de frontière de confiance. Le plugin Discourse AI traitait le LLM comme un composant interne et de confiance — comme on traite un template côté serveur — et sa sortie était donc rendue comme du markup. Mais la sortie d’un LLM est une fonction d’entrées contrôlées par l’attaquant. Chaque octet d’une réponse LLM hérite du niveau de confiance du token le plus adversarial de son contexte. Le bon modèle mental est celui que pousse le projet OWASP LLM dans LLM05 : un LLM est un utilisateur non fiable dont la sortie doit être sanitisée pour chaque sortie en aval — HTML, SQL, shell, chemins de fichiers, URLs. Le code Discourse est par ailleurs prudent sur le chemin du contenu de message, où le Markdown est sanitisé via une allow-list ; la fonctionnalité IA a simplement sauté cette étape parce que la donnée “venait de chez nous”.

Le second est la modération-comme-cible qui accompagne les fonctionnalités de triage IA en général. Tout produit qui embarque un workflow “le LLM examine du contenu utilisateur pour l’admin” hérite du même vecteur de livraison : l’attaquant rédige du contenu que l’outillage de l’admin rend à l’intérieur même de la session de l’admin. Le même schéma a déjà été observé dans des copilotes de support client, des résumeurs IA d’outils SOAR et des triages de tickets SOC. Si votre application embarque un LLM qui résume des entrées non fiables vers une UI admin, vous avez la condition préalable de cette classe de bug. L’audit de ces chemins de rendu est un P1.

Défenses

Le correctif livré par Discourse est le plancher, pas le plafond. Les leçons générales sont plus larges que ce seul plugin.

  1. Échapper la sortie LLM à chaque sink HTML. Considérez htmlSafe, dangerouslySetInnerHTML, v-html, bypass_sanitize et leurs équivalents comme des primitives sensibles à la sécurité qui exigent une justification écrite. Pour de la sortie LLM en particulier, la valeur par défaut sûre est l’échappement HTML standard du framework (ERB::Util.html_escape, vérifications Rails.html_safe?, rendu texte par défaut de React, autoescape de Django). Si vous avez besoin de texte riche venu d’un LLM, faites-le passer par un sanitiseur à allow-list (gem Sanitize, DOMPurify, bleach) — jamais brut.

  2. Activer une Content Security Policy et collecter les violations. Discourse active CSP par défaut depuis la 2.2, ce qui contraint matériellement ce qu’un <script> injecté dans la Review Queue peut faire. Pour toute UI admin exposée à de l’IA, une CSP stricte sans unsafe-inline et avec report-uri est la deuxième ligne de défense lorsqu’un sink passe à travers. Les rapports de violation CSP fournissent aussi le signal de détection mis en avant dans le writeup SentinelOne.

  3. Contraindre la forme de la sortie LLM. Le triage IA n’a pas besoin d’émettre du HTML. Forcez le modèle à produire une sortie structurée — JSON schema, validation Pydantic / Zod, structured outputs OpenAI, tool-use Anthropic — et rejetez les réponses qui contiennent du HTML ou des tokens de markup avant qu’elles n’atteignent un renderer. Un champ reason: string validé contre une regex de caractères autorisés aurait tué cette CVE en amont de la couche de templating.

  4. Modéliser la menace de chaque chemin “LLM résume du contenu utilisateur pour l’admin”. Inventoriez les fonctionnalités IA de votre produit, listez les sources d’entrée LLM (messages utilisateurs, emails, tickets, fetches web), et listez les sinks de rendu (panneaux admin, notifications Slack, digests email, enrichissement SIEM). Chaque couple (source, sink) est un candidat au même bug. Cartographiez-le, décidez du contrat de sanitisation, et écrivez un test de régression qui vérifie qu’une chaîne semblable à un payload fait l’aller-retour en texte, pas en markup.

  5. Patcher et faire tourner les sessions. Si vous exploitez Discourse avec le plugin IA activé, mettez à jour vers l’une des releases corrigées (2026.3.0-latest.1, 2026.2.1, 2026.1.2). Si vous avez retardé la mise à jour, le contournement temporaire de l’advisory est de désactiver les scripts d’automatisation de triage IA. Considérez les tokens de session des modérateurs émis avant le patch comme potentiellement exposés si des messages signalés ont été ouverts dans l’intervalle — la page GHSA de Discourse décrit le flux de rotation.

  6. Lire le patch, pas le score. Le CVSS 5,1 reflète la condition préalable de privilèges faibles et l’interaction utilisateur requise, deux contraintes inhérentes à un workflow de modération. Le rayon réel de l’impact est “tout admin qui fait son travail ouvre la file”, ce qui ressemble davantage à un vol de session qu’à un XSS de gravité moyenne typique. Utilisez les commits de correctif — 44b84439, 8ae7cb24, ed70949f — comme référence pour des revues à la grep des chemins de rendu de sortie LLM dans votre propre base de code.

Statut

ÉlémentRéférenceDateNotes
CVE publiéeNVD2026-03-19CVE-2026-27740
GitHub Security AdvisoryDiscourse2026-03-19GHSA-95hc-42c6-wvvr
Versions corrigéesDiscourse2026-032026.3.0-latest.1, 2026.2.1, 2026.1.2
CVSS 4.0NVD2026-03-195,1 MEDIUM, CWE-79
Chemins vulnérablesCommits de correctif2026-03llm_triage.rb, flag_post.rb (personas + agents)
ContournementAdvisory2026-03-19Désactiver le triage IA

Le bon résumé de CVE-2026-27740 n’est pas “Discourse avait un bug XSS” — cette partie est banale. C’est “un LLM a été traité comme un composant serveur de confiance, et sa sortie est passée par htmlSafe”. La même erreur de confiance existe dans tout produit qui laisse un modèle écrire dans un chemin de rendu côté admin. Le patch Discourse est un test décisif utile : si vous ne pouvez pas pointer la ligne de votre propre code où la sortie LLM est échappée HTML avant d’atteindre le navigateur, vous n’avez pas fini de corriger cette classe de bug.

Sources