Reports
MEDIA[XSS]#3994016

Rails Action Text: el contenido de <code> y <pre> rompe su delimitador en to_markdown e inyecta Markdown arbitrario

En la rama main de Rails, un cuerpo de texto enriquecido con <code> o <pre> podía escapar de su delimitador en ActionText::Content#to_markdown y colar enlaces javascript: o HTML sin escapar en el Markdown generado.

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

Action Text, el componente de texto enriquecido de Rails, incorporó en su rama de desarrollo (main, versión 8.2.0.alpha, commit 152594147f) un método ActionText::Content#to_markdown que convierte el HTML guardado a Markdown. Para que lo que escribe un usuario no se convierta en sintaxis Markdown, el conversor escapa los caracteres especiales ([, ], <, >...) en todos los nodos de texto. Además, en los enlaces reales (<a href>), visit_a rechaza los esquemas de URI que Rails::HTML::Sanitizer.allowed_uri? no permite, como javascript:.

Hay una excepción: el texto cuyo padre directo es <code> o <pre> (MarkdownConversion::SKIP_ESCAPING_PARENTS) no se escapa. Se da por hecho que queda neutralizado por el delimitador que lo rodea: una tira de acentos graves para el código en línea o una valla ``` ` ``` para los bloques. El problema es que el conversor nunca comprueba que ese delimitador funcione de verdad, y hay dos formas de romperlo:

  1. Líneas en blanco dentro de un code span. inline_code (markdown_conversion.rb:368-376) ajusta el número de acentos graves y añade relleno cuando el contenido empieza o acaba con uno, pero no tiene en cuenta las líneas en blanco. En CommonMark un code span no puede contener una línea en blanco: esta cierra el párrafo, el span no llega a cerrarse y todo lo que viene después se interpreta como Markdown normal. No hace falta un salto de línea literal: visit_br emite "\n", y <br> es como Trix representa los saltos de línea, así que basta con <br><br>.
  2. Vallas de código a mitad de línea. visit_pre (:180-184) emite una valla ``` ` ``, que solo abre un bloque de código si está al principio de una línea. visit__heading (:197-199) inserta esa salida dentro de una línea ya empezada (por ejemplo, tras # ), así que la valla no abre nada y el contenido queda activo. Lo mismo ocurre con visit_summary (:247-249), visit_tr (:237-245) y visit__table_header_row (:282-287`).

Por cualquiera de las dos vías, el cuerpo guardado puede producir sintaxis de enlace con un esquema que visit_a rechazaría, o < y > sin escapar.

La carga llega intacta hasta el conversor porque has_rich_text guarda el contenido a través de ActionText::Content, que lo canonicaliza pero no lo sanea (el saneado se hace al renderizar, en ContentHelper). De todos modos, code y pre están en la lista de etiquetas permitidas por defecto.

El investigador distingue este fallo de su reporte anterior #3727743, que afectaba a la etiqueta <action-text-markdown>. La corrección de aquel (2191ca0d6c) desempaqueta esa etiqueta durante la canonicalización y añade una nota a node_to_markdown que indica que el contenido no confiable debe pasar por ActionText::Content. Esta carga cumple esa condición (no contiene la etiqueta y Content.new(html).to_html da lo mismo con y sin canonicalización) y aun así se filtra. El investigador también volvió a comprobar que la vía de <action-text-markdown> sigue cerrada, incluidas las variantes con contenido <svg>, <math> y <template>. El código defectuoso es anterior: inline_code y code_fence llegaron en #56871 y visit_pre y visit__heading en #56858 (ambos del 24-feb-2026).

to_markdown solo existía en main, así que ninguna versión publicada de Rails se vio afectada.

Pasos de reproducción

  1. Clonar Rails y situarse en el commit 152594147f2dff25093829aebe8aa8eecf7de286 (entorno del investigador: Ruby 3.4, nokogiri 1.19.1, rails-html-sanitizer 1.7.0).
  2. Copiar el test adjunto al reporte, markdown_conversion_poc_test.rb, en actiontext/test/unit/.
  3. Ejecutarlo:
cd actiontext
bundle exec ruby -Itest test/unit/markdown_conversion_poc_test.rb
  1. Comprobar el resultado sobre main sin modificar:
12 runs, 25 assertions, 8 failures, 0 errors, 0 skips

Los tests de control pasan (el escapado funciona con la misma carga en texto normal). Los que fallan comprueban dos invariantes del Markdown generado: un code span no puede contener una línea en blanco, y una tira de ``` ` ``` que no empieza su línea debe cerrarse en esa misma línea. El test cubre los cuatro visitantes que provocan el caso de la valla.

Salidas de ActionText::Content#to_markdown (cuerpo guardado a la izquierda, resultado debajo):

"<p>[click](javascript:alert(1))</p>"                        (control)
  => "\\[click\\](javascript:alert(1))"

"<div><code>x<br><br>[click](javascript:alert(1))</code></div>"
  => "`x\n\n[click](javascript:alert(1))`"

"<h1><code>x<br><br>[click](javascript:alert(1))</code></h1>"
  => "# `x\n\n[click](javascript:alert(1))`"

"<blockquote><code>x<br><br>[click](javascript:alert(1))</code></blockquote>"
  => "> `x\n> \n> [click](javascript:alert(1))`"

"<h1><pre>[click](javascript:alert(1))</pre></h1>"
  => "# ```\n[click](javascript:alert(1))\n```"

"<p><code>x<br><br>&lt;img src=x onerror=alert(1)&gt;</code></p>"
  => "`x\n\n<img src=x onerror=alert(1)>`"

En el caso de control los corchetes salen escapados; dentro de <code> salen tal cual. El reporte incluía también una reproducción independiente (attachments/standalone/) que carga sin modificar los cuatro ficheros de Rails implicados en un contenedor con nokogiri, activesupport y rails-html-sanitizer, sin necesidad de una aplicación Rails.

Impacto

Un usuario que pueda publicar texto enriquecido pasa a controlar la estructura del Markdown que genera to_markdown, no solo su texto. Desde dentro de un <code> o <pre> puede abrir un bloque nuevo y emitir enlaces, encabezados, bloques HTML o cualquier otra sintaxis que el escapado debía impedir. Los enlaces así creados no pasan por la comprobación de esquema de visit_a, así que javascript: sobrevive.

El efecto final depende de quién consuma ese Markdown. Con los renderizadores en su configuración por defecto, el investigador obtuvo esto para <div><code>x<br><br>[click](javascript:alert(1))</code></div>:

redcarpet 3.6.1      <p>`x</p><p><a href="javascript:alert(1)">click</a>`</p>
kramdown 2.5.2       <p>`x</p><p><a href="javascript:alert(1)">click</a>`</p>
commonmarker 2.10.0  <p>`x</p><p><a href="">click</a>`</p>

Y para <p><code>x<br><br>&lt;img src=x onerror=alert(1)&gt;</code></p>:

redcarpet 3.6.1      <p>`x</p><p><img src=x onerror=alert(1)>`</p>
kramdown 2.5.2       escaped
commonmarker 2.10.0  <!-- raw HTML omitted -->
  • En una aplicación que renderice el Markdown generado con Redcarpet o kramdown por defecto, es XSS almacenado: un usuario guarda la carga y esta se ejecuta en el navegador de cualquiera que vea el resultado.
  • Con cmark-gfm (commonmarker) el esquema se elimina, pero igualmente aparece un enlace donde Rails prometía un literal escapado: inyección de contenido y estructura en lo que consuma el documento, como el cuerpo de un correo o una exportación.

El investigador subraya que la garantía rota es de Rails, no del renderizador: el módulo ya defiende que el Markdown generado no contenga enlaces con esquemas rechazados, con siete tests específicos para <a href> con javascript: ofuscado (markdown_conversion_test.rb:569-595), y este fallo llega al mismo resultado sin pasar por <a href>. Sobre la advertencia de la documentación de que la salida "no es HTML seguro", argumenta que solo indica que el resultado es Markdown y no HTML, y que no impidió que #3727743 se tratara como un defecto y se corrigiera.

Remediación

El investigador adjuntó un parche orientativo (SUGGESTED-PATCH.diff) para demostrar que ambos casos se pueden separar del comportamiento previsto: con él aplicado, los tests de la prueba de concepto pasan y markdown_conversion_test.rb sigue en 174 runs, 217 assertions, 0 failures. Dejó al equipo de Rails la decisión sobre cómo debía ser la corrección definitiva. El programa marcó el reporte como resuelto; el reporte no detalla el cambio que se aplicó finalmente.