Reports
MEDIA[XSS]#3779690

XSS almacenado en la exportación HTML de Rocket.Chat, inyectable sin cuenta desde el LiveChat

La exportación de salas a HTML de Rocket.Chat (y la descarga de datos personales) volcaba los mensajes sin escapar. Un visitante anónimo del LiveChat podía colar código que se ejecutaba al abrir el fichero exportado.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Rocket.Chat permite exportar una sala a un fichero HTML mediante la API rooms.export (con type=file y format=html). El código que genera ese fichero —la función exportMessageObject() en apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts— metía el texto de cada mensaje y el nombre de usuario directamente en el HTML, sin codificar entidades ni sanear nada:

const message = italicTypes.includes(messageType)
  ? `<i>${messageObject.msg}</i>`
  : messageObject.msg;

file.push(`<p><strong>${messageObject.username}</strong> (${timestamp}):<br/>`);
file.push(message);

Como messageObject.msg se inserta en crudo y el HTML generado no lleva cabecera CSP, cualquier etiqueta o manejador de eventos presente en un mensaje acaba tal cual en el fichero y se interpreta al abrirlo en el navegador. El investigador señala que, en ese mismo código base, la ruta de notificaciones por correo (apps/meteor/app/lib/server/functions/notifications/email.js) sí aplica escapeHTML() a los campos controlados por el usuario, lo que apunta a un olvido en la ruta de exportación y no a una decisión de diseño.

El punto importante es el origen del dato: un visitante anónimo del LiveChat (sin cuenta ni autenticación) puede escribir el mensaje malicioso. Además, la misma ruta vulnerable la reutiliza la función de descarga de datos personales (RGPD, users.requestDataDownload), así que un único mensaje en un canal compartido contamina la exportación de datos de cualquier miembro de ese canal.

Pasos de reproducción

Entorno usado en la prueba: Rocket.Chat 6.3.12 (imagen Docker rocket.chat:6.3), MongoDB 6.0 con replica set y sin servidor de correo configurado (los ficheros de exportación se recogían del /tmp/ del contenedor).

Los dos primeros pasos no requieren ninguna cabecera de autenticación.

Paso 1 — Registrar un visitante anónimo del LiveChat:

curl -X POST http://TARGET:3000/api/v1/livechat/visitor \
  -H 'Content-Type: application/json' \
  -d '{"visitor":{"name":"Customer","email":"[REDACTADO]","token":"poc-token-001"}}'

Paso 2 — Crear la sala de LiveChat (necesita un agente en línea):

curl http://TARGET:3000/api/v1/livechat/room?token=poc-token-001
# Devuelve: {"room":{"_id":"ROOM_ID",...}}

Paso 3 — Enviar como visitante anónimo un mensaje cuyo cuerpo (msg) contenga una etiqueta <img> con un manejador onerror. El endpoint es POST /api/v1/livechat/message con el token del visitante y el rid de la sala. Los campos name y msg aceptan cadenas arbitrarias sin validación, y las etiquetas HTML se guardan tal cual en la colección rocketchat_message. Sigue sin hacer falta autenticación.

Paso 4 — Exportar la sala a HTML (como agente o administrador):

curl -X POST http://TARGET:3000/api/v1/rooms.export \
  -H 'X-Auth-Token: [REDACTADO]' \
  -H 'X-User-Id: [REDACTADO]' \
  -H 'Content-Type: application/json' \
  -d '{"rid":"ROOM_ID","type":"file","format":"html","dateFrom":"2020-01-01","dateTo":"2030-01-01"}'

Paso 5 — Abrir en el navegador el fichero HTML exportado. El manejador onerror de la etiqueta <img> se dispara al cargar la página y el código del atacante se ejecuta en el origen file://.

La misma carga entra también por la ruta de descarga de datos personales: al pedir un usuario su exportación RGPD, un cron (userDataDownloadsCron) la procesa cada ~2 minutos y genera los ficheros HTML de todas las salas en las que participa.

Impacto

El código inyectado se ejecuta en el contexto file:// del fichero exportado. El investigador describe tres usos concretos:

  • Exfiltración del contenido exportado. El manejador lee todo el texto de la página —que es el historial completo de la conversación— y lo envía a un servidor del atacante (en la prueba, con un fetch al dominio del atacante y el contenido codificado en base64). Esto supone una escalada a nivel de datos: el atacante obtiene mensajes anteriores a su entrada en el canal y, por la vía de la exportación RGPD, posiblemente mensajes de canales de los que nunca fue miembro, porque la exportación de la víctima incluye todas sus salas. En un LiveChat, la exportación arrastra además notas internas, mensajes de otros agentes y eventos del sistema que el visitante no debería ver.
  • Phishing de credenciales. El código reescribe el contenido de la página para mostrar un formulario de inicio de sesión con la marca de Rocket.Chat ("sesión expirada, vuelve a iniciar sesión") que envía usuario y contraseña al servidor del atacante. Al abrirse en file:// no hay barra de direcciones que delate el dominio ni avisos de certificado, así que el formulario falso es indistinguible de uno legítimo.
  • Redirección a contenido malicioso, cambiando location hacia una URL del atacante (por ejemplo, una descarga automática).

Alcance y límites que el propio investigador reconoce: no es RCE ni toma de control directa de la API, porque el file:// está aislado y no puede acceder a las cookies, el localStorage ni hacer peticiones autenticadas a Rocket.Chat. Tampoco es zero-click: la víctima tiene que exportar la sala, descargar el ZIP y abrir el fichero HTML (tres acciones). El hecho de que las cookies se compartieran entre puertos de localhost es un artefacto del entorno de desarrollo (RFC 6265), no explotable en producción cuando la exportación se abre desde file://.

El investigador detalla qué rutas están afectadas: rooms.export (type=file, format=html) y la descarga RGPD users.requestDataDownload no escapaban nada; sendViaEmail.ts escapaba el mensaje con Message.parse() pero no el nombre de usuario (parcialmente vulnerable); mientras que sendTranscript.ts del LiveChat y la ruta de notificaciones por correo sí aplicaban escapado y no eran vulnerables.

Remediación

Aplicar escapeHTML() a todos los campos controlados por el usuario en exportRoomMessagesToFile.ts:

// Antes:
file.push(`<p><strong>${messageObject.username}</strong> (${timestamp}):<br/>`);
file.push(message);

// Después:
import { escapeHTML } from '@rocket.chat/string-helpers';
file.push(`<p><strong>${escapeHTML(messageObject.username)}</strong> (${escapeHTML(timestamp)}):<br/>`);
file.push(escapeHTML(message));

Como defensa en profundidad, el investigador propone añadir una cabecera CSP al HTML exportado, que bloquearía la ejecución de scripts y la carga de recursos externos aunque en el futuro se introdujese un campo sin escapar:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">

Y el mismo escapado para message.u.username en la plantilla de correo de sendViaEmail.ts.

Datos del reporte: debilidad clasificada como Cross-site Scripting (XSS) almacenado (CWE-79), severidad media, programa rocket_chat. Enviado el 3 de junio de 2026, divulgado el 16 de julio de 2026, estado Resuelto. El original no indica importe de recompensa ni CVE.