Keep Rails' own check on: redirect_to url_from(params[:next]) || account_path, with other trusted hosts in config.action_controller.allowed_redirect_hosts. url_from returns the value only for a path on this site or an allowed host, and redirect_to raises for anything else. Never add allow_other_host: true to a redirect built from request data.
- Find every redirect_to and redirect_back_or_to that takes params, cookies or the Referer, and every allow_other_host: true.
- Pass the requested target through url_from and fall back to a fixed page: redirect_to url_from(params[:next]) || account_path.
- List other hosts you trust in config.action_controller.allowed_redirect_hosts (Rails 8.1); url_from and redirect_to both accept them. An entry matches the whole host name; a leading dot (".example.test") also allows every subdomain, as in config.hosts.
- Keep config.action_controller.action_on_open_redirect = :raise (load_defaults 7.0 and later) and action_on_path_relative_redirect = :raise (8.1), so a redirect you missed fails instead of leaving.
- Add regression tests that sign in through the real form with this card's payloads and resolve the Location header the way a browser does before comparing origins.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
private
def redirect_after_login(next_url)
# allow_other_host was added when redirect_to raised UnsafeRedirectError for the docs site.
redirect_to next_url.presence || account_path, allow_other_host: true
endWith IMPL=vulnerable, 8 of 16 tests fail (8 exploit tests).
The fix
private
def redirect_after_login(next_url)
redirect_to url_from(next_url) || account_path
endWith IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
private
def redirect_after_login(next_url)
redirect_to checked_target(next_url), allow_other_host: true
end
def checked_target(next_url)
return account_path if next_url.blank?
# A path on this site...
return next_url if next_url.start_with?("/")
# ...or a full link back to one of our sites, app.example.test or docs.example.test.
return next_url if URI.parse(next_url).host.to_s.include?("example.test")
account_path
rescue URI::InvalidURIError
account_path
endWith IMPL=wrong, 3 of 16 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Each value arrives in a link to the real sign-in page, such as /login?next=%2F%2Fevil.example%2F.
# After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
ATTACKS = {
"an absolute URL to another site" => "https://evil.example/",
"a scheme-relative URL" => "//evil.example/",
"three slashes" => "///evil.example/",
"a backslash after the slash" => "/\\evil.example/",
"two backslashes" => "\\\\evil.example/",
"our host name as a user name" => "https://app.example.test@evil.example/",
"a look-alike host that contains an allowed name" => "https://docs.example.test.evil.example/",
"a backslash before the @" => "https://evil.example\\@docs.example.test/",
"encoded slashes" => "/%2F%2Fevil.example/",
"a javascript: URL" => "javascript:alert(document.domain)"
}.freeze
ATTACKS.each do |label, next_url|
test "exploit: #{label} lands on an allowed site" do
res = sign_in_with_next(next_url)
url = landing(res)
assert_includes ALLOWED_ORIGINS, url.origin, "next=#{next_url} sent the browser to #{res.headers['Location']}"
end
end- allow_other_host: true is the usual answer to ActionController::Redirecting::UnsafeRedirectError, and it switches the check off for every value: this card's vulnerable code is that one option.
- start_with?("/") accepts //evil.example/ and ///evil.example/; Rails passes a value that starts with // through unchanged, and browsers read it as a link to evil.example. That is half of this card's wrong implementation.
- A host test with include? or end_with? accepts docs.example.test.evil.example and every subdomain someone else can take over; allowed_redirect_hosts compares whole host names.
- Rails turns a path that starts with a single / into an absolute URL on the request's own host, so /\evil.example/ stays on this site in Rails; the same payload leaves through frameworks that send the path unchanged.
- Browsers refuse javascript: in a Location header, but the same unchecked value in an href runs as script; check next before you render it into a link too.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/ruby-rails does this for you, in the pinned Ruby container).
- Against your own app, sign in through /login?next=%2F%2Fevil.example%2F and /login?next=https%3A%2F%2Fapp.example.test%40evil.example%2F, resolve each Location header against the sign-in page and expect your own origin.
- grep for allow_other_host; Brakeman and Semgrep flag redirect_to params[...] in the controller, not a target that arrives through a helper, so keep the regression tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | ruby.rails.security.brakeman.check-redirect-to.check-redirect-to | No findings on this card, where next_url reaches redirect_to through a method argument. On a one-file probe it flagged redirect_to params[:next] with and without allow_other_host and the wrong start_with?("/") check, and not redirect_to url_from(params[:next]).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | ruby.rails.security.audit.xss.avoid-redirect.avoid-redirect | 1 finding on this card, the redirect_to account_path in SessionsController#create (a false positive), and on the probe every redirect, the fixed url_from one included: it is an audit rule that reports request data near redirect_to.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | p/security-audit reported ruby.lang.security.missing-csrf-protection on ApplicationController, a false positive: load_defaults 5.2 and later add protect_from_forgery with: :exception to ActionController::Base. Nothing else.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/ |
| CodeQL | rb/url-redirection | Not expected to report anything on this card: the redirect is in SessionsController#create, a POST action, which the query skips, and the implementation is a module included through const_get. Elsewhere it treats redirect_to with request data as a sink unless allow_other_host: false is written out (true and the Rails 7 default both count), url_from has no model (taint stops there, so the fixed code passes by omission) and start_with?("/") is not a barrier, so the wrong check would be flagged.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed |
| Brakeman | RedirectRedirect | No warnings on this card. On a one-file probe it reported redirect_to params[:next], allow_other_host: true and the wrong start_with?("/") version (Weak confidence), and not redirect_to params[:next] without allow_other_host, which Rails itself guards (action_on_open_redirect = :raise), or url_from.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (sign-in with has_secure_password, redirect_to and url_from) |
| rails (railties, actionpack, actionview, activerecord) | 8.1.4 |
| sqlite3 | 2.9.6 (bundles SQLite 3.53.2) |
| bcrypt | 3.1.22 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/ruby-railsfolder.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/redirect_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/redirect_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/redirect_test.rbWithout Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/redirect_test.rb'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/redirect_test.rb'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/redirect_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs open-redirect/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
- bundler 4.0.20
- rails 8.1.4
- minitest 6.0.6
- minitest-reporters 1.8.0
- sqlite3 2.9.6 (SQLite 3.53.2)
- bcrypt 3.1.22
- nokogiri 1.19.4
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 8 of 16 tests fail (8 exploit tests); wrong: 3 of 16 tests fail (3 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 8/16 fail (8 exploit tests: the absolute URL, //, ///, two backslashes as a 500, our host as a user name, the look-alike host, the backslash before the @ and javascript:); wrong 3/16 fail (3 exploit tests: //, /// and the look-alike host), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
- Dependency freshness
- Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.
- Notes
- The tests resolve Location with test/browser_url.rb, the parts of the WHATWG URL parser a Location needs (no WHATWG parser is packaged for Ruby). Rails made /\evil.example/ and /%2F%2Fevil.example/ absolute URLs on the app's own host, so even the vulnerable code stays on site for them; \\evil.example/ is a path-relative redirect, which load_defaults 8.1 makes raise, so the vulnerable code answers 500 there.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Rails API: ActionController::Redirecting (redirect_to, url_from)
- Rails Guides: configuring, allowed_redirect_hosts
- Rails Guides: configuring, action_on_open_redirect
- Rails Guides: Securing Rails Applications, redirection
- Brakeman: redirect
- WHATWG URL Standard: basic URL parser
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')