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:
- 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_bremite"\n", y<br>es como Trix representa los saltos de línea, así que basta con<br><br>. - 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 convisit_summary(:247-249),visit_tr(:237-245) yvisit__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
- Clonar Rails y situarse en el commit
152594147f2dff25093829aebe8aa8eecf7de286(entorno del investigador: Ruby 3.4, nokogiri 1.19.1, rails-html-sanitizer 1.7.0). - Copiar el test adjunto al reporte,
markdown_conversion_poc_test.rb, enactiontext/test/unit/. - Ejecutarlo:
cd actiontext
bundle exec ruby -Itest test/unit/markdown_conversion_poc_test.rb
- Comprobar el resultado sobre
mainsin 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><img src=x onerror=alert(1)></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><img src=x onerror=alert(1)></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.