Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix open redirects in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 27 KB
On this page

Steps

  1. Find every redirect_to and redirect_back_or_to that takes params, cookies or the Referer, and every allow_other_host: true.
  2. Pass the requested target through url_from and fall back to a fixed page: redirect_to url_from(params[:next]) || account_path.
  3. 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.
  4. 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.
  5. 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.

Vulnerable, fixed and wrong

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

Vulnerableapp/redirects/login_redirects/vulnerable.rbRuby
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
end

With IMPL=vulnerable, 8 of 16 tests fail (8 exploit tests).

The fix

Fixedapp/redirects/login_redirects/fixed.rbRuby
private

def redirect_after_login(next_url)
  redirect_to url_from(next_url) || account_path
end

With IMPL=fixed, 16 of 16 tests pass.

A fix that looks safe but is not

Wrong fixapp/redirects/login_redirects/wrong.rbRuby
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
end

With IMPL=wrong, 3 of 16 tests fail (3 exploit tests).

The regression test

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.

Testtest/redirect_test.rbRuby
# 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

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix open redirects in Ruby on Rails
ToolRuleWhat it found here
Semgrepruby.rails.security.brakeman.check-redirect-to.check-redirect-toNo 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>
Semgrepruby.rails.security.audit.xss.avoid-redirect.avoid-redirect1 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>
Semgrepp/ruby, p/owasp-top-ten, p/security-auditp/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/
CodeQLrb/url-redirectionNot 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
BrakemanRedirectRedirectNo 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)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (sign-in with has_secure_password, redirect_to and url_from)
rails (railties, actionpack, actionview, activerecord)8.1.4
sqlite32.9.6 (bundles SQLite 3.53.2)
bcrypt3.1.22
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

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.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# 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.rb

Without 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 27 KB

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

  1. OWASP Unvalidated Redirects and Forwards Cheat Sheet
  2. Rails API: ActionController::Redirecting (redirect_to, url_from)
  3. Rails Guides: configuring, allowed_redirect_hosts
  4. Rails Guides: configuring, action_on_open_redirect
  5. Rails Guides: Securing Rails Applications, redirection
  6. Brakeman: redirect
  7. WHATWG URL Standard: basic URL parser
  8. CWE-601: URL Redirection to Untrusted Site ('Open Redirect')

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.