Skip to content

Commit c3637ab

Browse files
committed
Allow to log/notify/raise on path relative redirects
This can lead to unexpected redirects, especially when the global `raise_on_open_redirects` setting is disabled since it allows redirects to different domains while calling `redirect_to` with a relative url. E.g. with the app running on https://example.com, calling `redirect_to("foo")`, will redirect to "https://example.comfoo" which can potentially be exploited. `redirect_to("@dell.com") is even worse since it redirects to "https://[email protected]" with `example.com`being interpreted as the user and dell.com as the domain. Allowing redirects to other hosts is very common in the authentication real, especially when using OAuth and guarding more applications from patterns like this makes sense (at least to me). This adds the option to log, notify or raise with the default for existing applications being log and the default for new applications being raise. Notify is added to allow existing applications to gather statistics about where this happens to analyze call sites individually, working on them and then being able to confidently turn the setting to raise.
1 parent 08b54b9 commit c3637ab

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)