Yet another parsing differential bug
Safe-Redirect
The normal pattern for throwing a 302 redirect in a rails application is by using the built-in method redirect_to
. As of rails v7.0, the default behaviour is to only allow relative redirects to locations on the same origin domain, unless the allow_other_host
flag is set to true. This functionality was designed to mitigate open redirect vulnerabilities, but a bug in the implementation allowed for a bypass. Let’s discuss!
First, let’s look at how this functionality is intended to work. I’ve set up a simple rails app where the /redirect
route will accept a GET param called to
and call redirect_to
using that value as the path to redirect the user to. The controller looks like this:
class RedirectController < ApplicationController
def index
unless params[:to].nil?
redirect_to(params[:to])
end
end
end
When params[:to]
is a relative path this works as you’d expect. For example, https://localhost:3000/redirect?to=/home
throws a 302 that redirects us to /home
:
If we insteaed give it a URL pointing to a different domain it throws an UnsafeRedirectError:
The goal is to bypass this control and get an offsite redirect to fire without raising this exception. This ended up being possible because ruby parses URIs differently than modern browsers do. Let’s start by digging into rails’ safe redirect code.
When calling redirect_to
without enabling the allow_other_host
flag, rails passes the url off to a validation function called _url_host_allowed?
.
def _url_host_allowed?(url)
host = URI(url.to_s).host
host == request.host || host.nil? && url.to_s.start_with?("/")
rescue ArgumentError, URI::Error
false
end
This function attempts to validate that the path a user is being redirected to is on the same host as the one they’re being redirected from. In order to satisfy this validation one of two conditions must be met. These are:
host == request.host
- which passes if the host in the redirect uri matches the applications host.host.nil? && url.to_s.start_with?("/")
- which is supposed to match only on relative uris like/somepath
but not likehttp://evil.com
Only one of these conditions must to be satisfied to pass the validation, and it turns out that the second one has a flaw. We can trick this validator into believing that an absolute URI pointing to some external host is actually a relative URI pointing to some path on the current host. Based on the above code, rails is defining a relative uri as one that
- has a
nil
host - starts with a
/
In order to make this work we will chain together a convenience built into modern browsers and a quirk of ruby’s URI parser.
First, URIs that start with a /
are normally relative paths pointing to something on the current host, while uris starting with a scheme like https://
are absolute uris that can point to any host on the internet. As a convenience, modern browsers will take uris starting with //
and convert that to the full scheme format https://
. You can test this right now by typing the following into the javascript console in your browser: location.href="//google.com"
.
This is useful for us because it means that it’s possible to construct an absolute uri that would pass uri.to_s.start_with?("/")
.
Second, Ruby’s URI parser conforms to a different spec than modern web browsers, and a subtle difference between these two specifications will allow us to bypass the host.nil?
requirement. Modern browsers obey the whatwg spec, which requires the parser to fix urls with too many slashes after the scheme:
Ruby’s URI parser sees this differently. If supplied a URI with 3 slashes in the scheme, it will parse this as though the host is simply the empty string between the second and third slash. Same is true when the scheme is omitted:
irb(main):001:0> URI("https:///test.com/").host
=> nil
irb(main):002:0> URI("///test.com/").host
=> nil
NOTE: I reported this behaviour to Ruby last year, but they determined this to not be a bug as this is technically spec compliant, just a different set of specs than browsers use.
This means that a url like ///evil.com
given to ruby will return true on host.nil?
, but when given to a browser will be seen as pointing to https://evil.com
This was a universal bypass for Rails 7.0’s safe-redirect functionality. It has now been patched.