sistema: OPERATIVO
← volver a todos los hacks
INDIRECT INJECTION MEDIUM

XSS en Discourse AI (CVE-2026-27740): cuando la salida de un LLM se trata como HTML de confianza

Un mensaje reportado, un moderador IA, una llamada a htmlSafe. El plugin Discourse AI trataba la salida del LLM como marcado de confianza, convirtiendo una prompt injection indirecta en XSS contra el staff. Publicado el 19 de marzo de 2026.

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

¿De qué se trata?

El 19 de marzo de 2026, el equipo de Discourse publicó el aviso GHSA-95hc-42c6-wvvr y obtuvo el identificador CVE-2026-27740 — una falla de cross-site scripting persistente en el plugin Discourse AI, disparada mediante prompt injection indirecta de un moderador basado en LLM. La vulnerabilidad se incorporó al NVD ese mismo día con una puntuación CVSS 4.0 de 5,1 (MEDIUM) y está corregida en Discourse 2026.3.0-latest.1, 2026.2.1 y 2026.1.2.

La forma del bug es pequeña y didáctica: una automatización de triaje con IA lee un mensaje reportado, le pide a un LLM que resuma el motivo y renderiza ese resumen en la Review Queue del staff. El renderizador invoca htmlSafe sobre la respuesta del LLM. Un atacante redacta un mensaje diseñado para manipular al modelo y forzarlo a devolver una etiqueta <script> dentro de su “motivo”, y el payload se ejecuta la próxima vez que un moderador abre la cola. Esto es exactamente OWASP LLM05 — Improper Output Handling — observado en producción (OWASP LLM Top 10).

Cómo funciona

Tres actores entran en juego: el atacante que escribe un mensaje, el job de triaje IA que llama a un LLM, y el miembro del staff que abre la Review Queue.

[Mensaje del atacante]
   │ contiene contenido diseñado para manipular el campo "motivo" del LLM

[Automatización Discourse AI triage]
   │ llm_triage.rb — envía el mensaje al LLM configurado

[Respuesta del LLM]
   │ contiene marcado controlado por el atacante, p. ej. un payload tipo script

[Plantilla I18n "discourse_automation.scriptables.llm_triage.flagged_post"]
   │ interpola llm_response y automation_name como `htmlSafe`

[UI Review Queue en el navegador del staff]
   │ el payload se ejecuta en una sesión admin autenticada

[Robo de sesión / acciones admin / manipulación de configuración]

La causa raíz está documentada en el commit del parche 44b84439. Antes del fix, plugins/discourse-ai/lib/automation/llm_triage.rb pasaba la respuesta del LLM directamente a la llamada de traducción:

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

La plantilla flagged_post renderizaba esos valores con htmlSafe, que es la opción explícita de Rails para evitar el escape HTML. El parche envuelve ambos campos con ERB::Util.html_escape:

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

Se aplican correcciones equivalentes en plugins/discourse-ai/lib/personas/tools/flag_post.rb y plugins/discourse-ai/lib/agents/tools/flag_post.rb. Aquí no se reproduce ningún payload explotable; el diff público del commit es la referencia canónica para los defensores.

La interacción del usuario (UI:P en el vector CVSS) es el miembro del staff abriendo la cola — algo que le pagan por hacer. Esto es lo que hace que un CVSS 5,1 “medio” pese más de lo que parece en la práctica: el disparador forma parte del flujo normal del moderador.

Por qué importa

Dos patrones hacen que esta CVE merezca atención más allá de Discourse.

El primero es el error de frontera de confianza. El plugin Discourse AI trataba al LLM como un componente interno y de confianza — al mismo nivel que una plantilla del lado del servidor — y por eso su salida se renderizaba como marcado. Pero la salida de un LLM es una función de entradas controladas por el atacante. Cada byte de una respuesta LLM hereda el nivel de confianza del token más adversarial de su contexto. El modelo mental correcto es el que impulsa el proyecto OWASP LLM en LLM05: un LLM es un usuario no confiable cuya salida debe sanitizarse para cada sumidero downstream — HTML, SQL, shell, rutas de archivo, URLs. El código de Discourse es por lo demás cuidadoso en la ruta del contenido del mensaje, donde el Markdown se sanitiza vía allow-list; la función de IA simplemente se saltó ese paso porque los datos “venían de nosotros”.

El segundo es el giro de moderador-como-objetivo que acompaña a las funciones de triaje con IA en general. Cualquier producto que incluya un flujo “el LLM revisa contenido del usuario para el admin” hereda el mismo vehículo de entrega: el atacante escribe contenido que la herramienta del admin renderiza dentro de la propia sesión del admin. El mismo patrón ha aparecido en copilotos de soporte, resumidores IA de orquestadores de seguridad y triaje de tickets SOC. Si su aplicación tiene un LLM que resume entrada no confiable en una UI dirigida a administradores, tiene la condición previa para esta clase de bug. Trate la auditoría de esas rutas de renderizado como P1.

Defensas

El fix que envió Discourse es el piso, no el techo. Las lecciones generales son más amplias que un único plugin.

  1. Escapar la salida LLM en cada sumidero HTML. Trate htmlSafe, dangerouslySetInnerHTML, v-html, bypass_sanitize y similares como primitivas sensibles a la seguridad que requieren una justificación escrita. Para salida LLM en particular, el valor por defecto seguro es el escape HTML estándar del framework (ERB::Util.html_escape, comprobaciones Rails.html_safe?, renderizado de texto por defecto en React, autoescape de Django). Si necesita texto enriquecido desde un LLM, páselo por un sanitizador con allow-list (gem Sanitize, DOMPurify, bleach) — nunca crudo.

  2. Habilitar Content Security Policy y reportar violaciones. Discourse habilita CSP por defecto desde la 2.2, lo que restringe materialmente lo que un <script> inyectado en la Review Queue puede hacer. Para cualquier UI de admin expuesta a IA, una CSP estricta sin unsafe-inline y con report-uri es la segunda línea de defensa cuando un sumidero se cuela. Los reportes de violación CSP también proporcionan la señal de detección destacada en el writeup de SentinelOne.

  3. Restringir la forma de la salida del LLM. El triaje IA no necesita emitir HTML. Force al modelo a producir salida estructurada — JSON schema, validación con Pydantic / Zod, structured outputs de OpenAI, tool-use de Anthropic — y rechace las respuestas que contengan HTML o tokens de marcado antes de que lleguen a un renderizador. Un campo reason: string validado contra una regex de caracteres permitidos habría matado esta CVE río arriba de la capa de plantillado.

  4. Modelar la amenaza en cada ruta “LLM resume contenido del usuario para el admin”. Inventaríe las funciones de IA que su producto envía, liste las fuentes de entrada del LLM (mensajes de usuario, emails, tickets, fetches web), y liste los sumideros de renderizado (paneles admin, notificaciones Slack, digests por email, enriquecimiento SIEM). Cada par (fuente, sumidero) es un candidato al mismo bug. Mapéelo, decida el contrato de sanitización y escriba un test de regresión que verifique que una cadena tipo payload viaja como texto, no como marcado.

  5. Parchee y rote sesiones. Si opera Discourse con el plugin IA habilitado, actualice a alguna de las releases corregidas (2026.3.0-latest.1, 2026.2.1, 2026.1.2). Si retrasó la actualización, el workaround temporal del aviso es deshabilitar los scripts de automatización de triaje IA. Considere los tokens de sesión de moderadores emitidos antes del parche como potencialmente expuestos si se abrieron mensajes reportados durante el intervalo — la página GHSA de Discourse describe el flujo de rotación.

  6. Lea el parche, no el puntaje. El CVSS 5,1 refleja la condición previa de bajos privilegios y la interacción del usuario requerida, ambas inherentes a un flujo de moderación. El radio real de impacto es “cualquier admin que haga su trabajo abre la cola”, lo que se parece más a un secuestro de sesión que a un XSS de severidad media típica. Utilice los commits del parche — 44b84439, 8ae7cb24, ed70949f — como referencia para revisiones tipo grep de sus propias rutas de renderizado de salida LLM.

Estado

ElementoReferenciaFechaNotas
CVE publicadaNVD2026-03-19CVE-2026-27740
GitHub Security AdvisoryDiscourse2026-03-19GHSA-95hc-42c6-wvvr
Versiones parcheadasDiscourse2026-032026.3.0-latest.1, 2026.2.1, 2026.1.2
CVSS 4.0NVD2026-03-195,1 MEDIUM, CWE-79
Rutas vulnerablesCommits del parche2026-03llm_triage.rb, flag_post.rb (personas + agents)
WorkaroundAdvisory2026-03-19Deshabilitar el triaje IA

El resumen correcto de CVE-2026-27740 no es “Discourse tuvo un bug XSS” — esa parte es rutinaria. Es “un LLM fue tratado como un componente confiable del lado del servidor, y su salida pasó por htmlSafe”. El mismo error de confianza existe en cualquier producto que permita a un modelo escribir en una ruta de renderizado expuesta a administradores. El parche de Discourse es una prueba útil: si no puede señalar la línea de su propio código donde la salida LLM se escapa HTML antes de llegar al navegador, no ha terminado de corregir esta clase de bug.

Sources