Skip to content

Commit 46d2afa

Browse files
authored
Merge pull request rails#55308 from Shopify/raise-on-relative-redirects-without-slash
Allow to log/notify/raise on path relative redirects
2 parents 7293e81 + c3637ab commit 46d2afa

File tree

6 files changed

+275
-0
lines changed

6 files changed

+275
-0
lines changed

actionpack/lib/action_controller/metal/redirecting.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class UnsafeRedirectError < StandardError; end
1515

1616
included do
1717
mattr_accessor :raise_on_open_redirects, default: false
18+
mattr_accessor :action_on_path_relative_redirect, default: :log
1819
end
1920

2021
# Redirects the browser to the target specified in `options`. This parameter can
@@ -100,6 +101,26 @@ class UnsafeRedirectError < StandardError; end
100101
#
101102
# See #url_from for more information on what an internal and safe URL is, or how
102103
# to fall back to an alternate redirect URL in the unsafe case.
104+
#
105+
# ### Path Relative URL Redirect Protection
106+
#
107+
# Rails also protects against potentially unsafe path relative URL redirects that don't
108+
# start with a leading slash. These can create security vulnerabilities:
109+
#
110+
# redirect_to "example.com" # Creates http://yourdomain.comexample.com
111+
# redirect_to "@attacker.com" # Creates http://[email protected]
112+
# # which browsers interpret as user@host
113+
#
114+
# You can configure how Rails handles these cases using:
115+
#
116+
# config.action_controller.action_on_path_relative_redirect = :log # default
117+
# config.action_controller.action_on_path_relative_redirect = :notify
118+
# config.action_controller.action_on_path_relative_redirect = :raise
119+
#
120+
# * `:log` - Logs a warning but allows the redirect
121+
# * `:notify` - Sends an ActiveSupport notification but allows the redirect
122+
# (includes stack trace to help identify the source)
123+
# * `:raise` - Raises an UnsafeRedirectError
103124
def redirect_to(options = {}, response_options = {})
104125
raise ActionControllerError.new("Cannot redirect to nil!") unless options
105126
raise AbstractController::DoubleRenderError if response_body
@@ -166,6 +187,10 @@ def _compute_redirect_to_location(request, options) # :nodoc:
166187
when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i
167188
options.to_str
168189
when String
190+
if !options.start_with?("/") && !options.empty?
191+
_handle_path_relative_redirect(options)
192+
end
193+
169194
request.protocol + request.host_with_port + options
170195
when Proc
171196
_compute_redirect_to_location request, instance_eval(&options)
@@ -248,5 +273,22 @@ def _ensure_url_is_http_header_safe(url)
248273
raise UnsafeRedirectError, msg
249274
end
250275
end
276+
277+
def _handle_path_relative_redirect(url)
278+
message = "Path relative URL redirect detected: #{url.inspect}"
279+
280+
case action_on_path_relative_redirect
281+
when :log
282+
logger&.warn message
283+
when :notify
284+
ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller",
285+
url: url,
286+
message: message,
287+
stack_trace: caller
288+
)
289+
when :raise
290+
raise UnsafeRedirectError, message
291+
end
292+
end
251293
end
252294
end

actionpack/lib/action_controller/railtie.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module ActionController
1313
class Railtie < Rails::Railtie # :nodoc:
1414
config.action_controller = ActiveSupport::OrderedOptions.new
1515
config.action_controller.raise_on_open_redirects = false
16+
config.action_controller.action_on_path_relative_redirect = :log
1617
config.action_controller.log_query_tags_around_actions = true
1718
config.action_controller.wrap_parameters_by_default = false
1819

actionpack/test/controller/redirect_test.rb

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "abstract_unit"
4+
require "active_support/log_subscriber/test_helper"
45

56
class Workshop
67
extend ActiveModel::Naming
@@ -154,6 +155,14 @@ def redirect_to_url_with_network_path_reference
154155
redirect_to "//www.rubyonrails.org/"
155156
end
156157

158+
def redirect_to_path_relative_url
159+
redirect_to "example.com"
160+
end
161+
162+
def redirect_to_path_relative_url_starting_with_an_at
163+
redirect_to "@example.com"
164+
end
165+
157166
def redirect_to_existing_record
158167
redirect_to Workshop.new(5)
159168
end
@@ -335,6 +344,18 @@ def test_redirect_to_url_with_complex_scheme
335344
assert_equal "x-test+scheme.complex:redirect", redirect_to_url
336345
end
337346

347+
def test_redirect_to_path_relative_url
348+
get :redirect_to_path_relative_url
349+
assert_response :redirect
350+
assert_equal "http://test.hostexample.com", redirect_to_url
351+
end
352+
353+
def test_redirect_to_url_with_path_relative_url_starting_with_an_at
354+
get :redirect_to_path_relative_url_starting_with_an_at
355+
assert_response :redirect
356+
assert_equal "http://[email protected]", redirect_to_url
357+
end
358+
338359
def test_redirect_to_url_with_network_path_reference
339360
get :redirect_to_url_with_network_path_reference
340361
assert_response :redirect
@@ -620,6 +641,180 @@ def test_redirect_to_external_with_rescue
620641
assert_response :ok
621642
end
622643

644+
def test_redirect_to_path_relative_url_with_log
645+
old_config = ActionController::Base.action_on_path_relative_redirect
646+
ActionController::Base.action_on_path_relative_redirect = :log
647+
648+
logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new
649+
old_logger = ActionController::Base.logger
650+
ActionController::Base.logger = logger
651+
652+
get :redirect_to_path_relative_url
653+
assert_response :redirect
654+
assert_equal "http://test.hostexample.com", redirect_to_url
655+
assert_match(/Path relative URL redirect detected: "example.com"/, logger.logged(:warn).last)
656+
ensure
657+
ActionController::Base.logger = old_logger
658+
ActionController::Base.action_on_path_relative_redirect = old_config
659+
end
660+
661+
def test_redirect_to_path_relative_url_starting_with_an_at_with_log
662+
old_config = ActionController::Base.action_on_path_relative_redirect
663+
ActionController::Base.action_on_path_relative_redirect = :log
664+
665+
logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new
666+
old_logger = ActionController::Base.logger
667+
ActionController::Base.logger = logger
668+
669+
get :redirect_to_path_relative_url_starting_with_an_at
670+
assert_response :redirect
671+
assert_equal "http://[email protected]", redirect_to_url
672+
assert_match(/Path relative URL redirect detected: "@example.com"/, logger.logged(:warn).last)
673+
ensure
674+
ActionController::Base.logger = old_logger
675+
ActionController::Base.action_on_path_relative_redirect = old_config
676+
end
677+
678+
def test_redirect_to_path_relative_url_starting_with_an_at_with_notify
679+
old_config = ActionController::Base.action_on_path_relative_redirect
680+
ActionController::Base.action_on_path_relative_redirect = :notify
681+
682+
events = []
683+
ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
684+
events << ActiveSupport::Notifications::Event.new(*args)
685+
end
686+
687+
get :redirect_to_path_relative_url_starting_with_an_at
688+
689+
assert_response :redirect
690+
assert_equal "http://[email protected]", redirect_to_url
691+
692+
assert_equal 1, events.size
693+
event = events.first
694+
assert_equal "@example.com", event.payload[:url]
695+
assert_equal 'Path relative URL redirect detected: "@example.com"', event.payload[:message]
696+
assert_kind_of Array, event.payload[:stack_trace]
697+
assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url_starting_with_an_at") }
698+
ensure
699+
ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
700+
ActionController::Base.action_on_path_relative_redirect = old_config
701+
end
702+
703+
def test_redirect_to_path_relative_url_with_notify
704+
old_config = ActionController::Base.action_on_path_relative_redirect
705+
ActionController::Base.action_on_path_relative_redirect = :notify
706+
707+
events = []
708+
ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
709+
events << ActiveSupport::Notifications::Event.new(*args)
710+
end
711+
712+
get :redirect_to_path_relative_url
713+
714+
assert_response :redirect
715+
assert_equal "http://test.hostexample.com", redirect_to_url
716+
717+
assert_equal 1, events.size
718+
event = events.first
719+
assert_equal "example.com", event.payload[:url]
720+
assert_equal 'Path relative URL redirect detected: "example.com"', event.payload[:message]
721+
assert_kind_of Array, event.payload[:stack_trace]
722+
assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url") }
723+
ensure
724+
ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
725+
ActionController::Base.action_on_path_relative_redirect = old_config
726+
end
727+
728+
def test_redirect_to_path_relative_url_with_raise
729+
old_config = ActionController::Base.action_on_path_relative_redirect
730+
ActionController::Base.action_on_path_relative_redirect = :raise
731+
732+
error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do
733+
get :redirect_to_path_relative_url
734+
end
735+
736+
assert_equal 'Path relative URL redirect detected: "example.com"', error.message
737+
ensure
738+
ActionController::Base.action_on_path_relative_redirect = old_config
739+
end
740+
741+
def test_redirect_to_path_relative_url_starting_with_an_at_with_raise
742+
old_config = ActionController::Base.action_on_path_relative_redirect
743+
ActionController::Base.action_on_path_relative_redirect = :raise
744+
745+
error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do
746+
get :redirect_to_path_relative_url_starting_with_an_at
747+
end
748+
749+
assert_equal 'Path relative URL redirect detected: "@example.com"', error.message
750+
ensure
751+
ActionController::Base.action_on_path_relative_redirect = old_config
752+
end
753+
754+
def test_redirect_to_absolute_url_does_not_log
755+
old_config = ActionController::Base.action_on_path_relative_redirect
756+
ActionController::Base.action_on_path_relative_redirect = :log
757+
758+
logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new
759+
old_logger = ActionController::Base.logger
760+
ActionController::Base.logger = logger
761+
762+
get :redirect_to_url
763+
assert_response :redirect
764+
assert_equal "http://www.rubyonrails.org/", redirect_to_url
765+
assert_empty logger.logged(:warn)
766+
767+
get :relative_url_redirect_with_status
768+
assert_response :redirect
769+
assert_equal "http://test.host/things/stuff", redirect_to_url
770+
assert_empty logger.logged(:warn)
771+
ensure
772+
ActionController::Base.logger = old_logger
773+
ActionController::Base.action_on_path_relative_redirect = old_config
774+
end
775+
776+
def test_redirect_to_absolute_url_does_not_notify
777+
old_config = ActionController::Base.action_on_path_relative_redirect
778+
ActionController::Base.action_on_path_relative_redirect = :notify
779+
780+
events = []
781+
ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
782+
events << ActiveSupport::Notifications::Event.new(*args)
783+
end
784+
785+
get :redirect_to_url
786+
assert_response :redirect
787+
assert_equal "http://www.rubyonrails.org/", redirect_to_url
788+
assert_empty events
789+
790+
get :relative_url_redirect_with_status
791+
assert_response :redirect
792+
assert_equal "http://test.host/things/stuff", redirect_to_url
793+
assert_empty events
794+
ensure
795+
ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
796+
ActionController::Base.action_on_path_relative_redirect = old_config
797+
end
798+
799+
def test_redirect_to_absolute_url_does_not_raise
800+
old_config = ActionController::Base.action_on_path_relative_redirect
801+
ActionController::Base.action_on_path_relative_redirect = :raise
802+
803+
get :redirect_to_url
804+
assert_response :redirect
805+
assert_equal "http://www.rubyonrails.org/", redirect_to_url
806+
807+
get :relative_url_redirect_with_status
808+
assert_response :redirect
809+
assert_equal "http://test.host/things/stuff", redirect_to_url
810+
811+
get :redirect_to_url_with_network_path_reference
812+
assert_response :redirect
813+
assert_equal "//www.rubyonrails.org/", redirect_to_url
814+
ensure
815+
ActionController::Base.action_on_path_relative_redirect = old_config
816+
end
817+
623818
private
624819
def with_raise_on_open_redirects
625820
old_raise_on_open_redirects = ActionController::Base.raise_on_open_redirects

guides/source/configuring.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Below are the default values associated with each target version. In cases of co
6060

6161
#### Default Values for Target Version 8.1
6262

63+
- [`config.action_controller.action_on_path_relative_redirect`](#config-action-controller-action-on-path-relative-redirect): `:raise`
6364
- [`config.action_controller.escape_json_responses`](#config-action-controller-escape-json-responses): `false`
6465
- [`config.active_record.raise_on_missing_required_finder_order_columns`](#config-active-record-raise-on-missing-required-finder-order-columns): `true`
6566
- [`config.yjit`](#config-yjit): `!Rails.env.local?`
@@ -1970,6 +1971,26 @@ The default value depends on the `config.load_defaults` target version:
19701971

19711972
[redirect_to]: https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
19721973

1974+
#### `config.action_controller.action_on_path_relative_redirect`
1975+
1976+
Controls how Rails handles paths relative URL redirects.
1977+
1978+
When set to `:log` (default), Rails will log a warning when a path relative URL redirect
1979+
is detected. When set to `:notify`, Rails will publish an
1980+
`unsafe_redirect.action_controller` notification event. When set to `:raise`, Rails
1981+
will raise an `ActionController::Redirecting::UnsafeRedirectError`.
1982+
1983+
This helps detect potentially unsafe redirects that could be exploited for open
1984+
redirect attacks.
1985+
1986+
The default value depends on the `config.load_defaults` target version:
1987+
1988+
| Starting with version | The default value is |
1989+
| --------------------- | -------------------- |
1990+
| (original) | `:log` |
1991+
| 8.1 | `:raise` |
1992+
1993+
19731994
#### `config.action_controller.log_query_tags_around_actions`
19741995

19751996
Determines whether controller context for query tags will be automatically

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ def load_defaults(target_version)
358358

359359
if respond_to?(:action_controller)
360360
action_controller.escape_json_responses = false
361+
action_controller.action_on_path_relative_redirect = :raise
361362
end
362363

363364
if respond_to?(:active_record)

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,18 @@
3636
# Rails 8.2.
3737
#++
3838
# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true
39+
40+
###
41+
# Controls how Rails handles path relative URL redirects.
42+
# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError`
43+
# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities.
44+
#
45+
# Example:
46+
# redirect_to "example.com" # Raises UnsafeRedirectError
47+
# redirect_to "@attacker.com" # Raises UnsafeRedirectError
48+
# redirect_to "/safe/path" # Works correctly
49+
#
50+
# Applications that want to allow these redirects can set the config to `:log` (previous default)
51+
# to only log warnings, or `:notify` to send ActiveSupport notifications.
52+
#++
53+
# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise

0 commit comments

Comments
 (0)