actionpack 8.0.3 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +337 -158
  3. data/lib/abstract_controller/asset_paths.rb +4 -2
  4. data/lib/abstract_controller/base.rb +12 -3
  5. data/lib/abstract_controller/caching.rb +6 -3
  6. data/lib/abstract_controller/helpers.rb +1 -1
  7. data/lib/abstract_controller/logger.rb +2 -1
  8. data/lib/action_controller/api.rb +1 -0
  9. data/lib/action_controller/base.rb +2 -1
  10. data/lib/action_controller/caching.rb +1 -2
  11. data/lib/action_controller/form_builder.rb +1 -1
  12. data/lib/action_controller/log_subscriber.rb +18 -3
  13. data/lib/action_controller/metal/allow_browser.rb +1 -1
  14. data/lib/action_controller/metal/conditional_get.rb +25 -0
  15. data/lib/action_controller/metal/data_streaming.rb +1 -3
  16. data/lib/action_controller/metal/exceptions.rb +5 -0
  17. data/lib/action_controller/metal/flash.rb +1 -4
  18. data/lib/action_controller/metal/head.rb +3 -1
  19. data/lib/action_controller/metal/live.rb +9 -18
  20. data/lib/action_controller/metal/permissions_policy.rb +9 -0
  21. data/lib/action_controller/metal/rate_limiting.rb +30 -9
  22. data/lib/action_controller/metal/redirecting.rb +105 -13
  23. data/lib/action_controller/metal/renderers.rb +27 -6
  24. data/lib/action_controller/metal/rendering.rb +7 -1
  25. data/lib/action_controller/metal/request_forgery_protection.rb +18 -10
  26. data/lib/action_controller/metal/rescue.rb +9 -0
  27. data/lib/action_controller/railtie.rb +27 -8
  28. data/lib/action_controller/structured_event_subscriber.rb +112 -0
  29. data/lib/action_dispatch/http/cache.rb +111 -1
  30. data/lib/action_dispatch/http/filter_parameters.rb +5 -3
  31. data/lib/action_dispatch/http/mime_negotiation.rb +55 -1
  32. data/lib/action_dispatch/http/mime_types.rb +1 -0
  33. data/lib/action_dispatch/http/param_builder.rb +28 -27
  34. data/lib/action_dispatch/http/parameters.rb +3 -3
  35. data/lib/action_dispatch/http/permissions_policy.rb +4 -0
  36. data/lib/action_dispatch/http/query_parser.rb +12 -10
  37. data/lib/action_dispatch/http/request.rb +10 -5
  38. data/lib/action_dispatch/http/response.rb +16 -3
  39. data/lib/action_dispatch/http/url.rb +110 -14
  40. data/lib/action_dispatch/journey/gtg/simulator.rb +33 -12
  41. data/lib/action_dispatch/journey/gtg/transition_table.rb +33 -41
  42. data/lib/action_dispatch/journey/nodes/node.rb +2 -1
  43. data/lib/action_dispatch/journey/route.rb +45 -31
  44. data/lib/action_dispatch/journey/router/utils.rb +8 -14
  45. data/lib/action_dispatch/journey/router.rb +59 -81
  46. data/lib/action_dispatch/journey/routes.rb +7 -0
  47. data/lib/action_dispatch/journey/visitors.rb +55 -23
  48. data/lib/action_dispatch/journey/visualizer/fsm.js +4 -6
  49. data/lib/action_dispatch/log_subscriber.rb +7 -3
  50. data/lib/action_dispatch/middleware/cookies.rb +4 -2
  51. data/lib/action_dispatch/middleware/debug_exceptions.rb +7 -1
  52. data/lib/action_dispatch/middleware/debug_view.rb +11 -0
  53. data/lib/action_dispatch/middleware/exception_wrapper.rb +11 -5
  54. data/lib/action_dispatch/middleware/executor.rb +12 -2
  55. data/lib/action_dispatch/middleware/public_exceptions.rb +1 -5
  56. data/lib/action_dispatch/middleware/remote_ip.rb +9 -4
  57. data/lib/action_dispatch/middleware/session/cache_store.rb +17 -0
  58. data/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb +1 -0
  59. data/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +3 -2
  60. data/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +9 -5
  61. data/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +1 -0
  62. data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +1 -0
  63. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +4 -0
  64. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +3 -0
  65. data/lib/action_dispatch/middleware/templates/rescues/layout.erb +50 -0
  66. data/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +1 -0
  67. data/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +1 -0
  68. data/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +1 -0
  69. data/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +1 -0
  70. data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -0
  71. data/lib/action_dispatch/railtie.rb +14 -2
  72. data/lib/action_dispatch/routing/inspector.rb +79 -56
  73. data/lib/action_dispatch/routing/mapper.rb +324 -172
  74. data/lib/action_dispatch/routing/redirection.rb +10 -7
  75. data/lib/action_dispatch/routing/route_set.rb +2 -4
  76. data/lib/action_dispatch/structured_event_subscriber.rb +20 -0
  77. data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +2 -2
  78. data/lib/action_dispatch/testing/assertions/response.rb +14 -0
  79. data/lib/action_dispatch/testing/assertions/routing.rb +11 -3
  80. data/lib/action_dispatch/testing/integration.rb +1 -1
  81. data/lib/action_dispatch/testing/request_encoder.rb +9 -9
  82. data/lib/action_dispatch.rb +8 -0
  83. data/lib/action_pack/gem_version.rb +2 -2
  84. metadata +13 -10
@@ -3,7 +3,6 @@
3
3
  # :markup: markdown
4
4
 
5
5
  require "abstract_controller/error"
6
- require "active_support/configurable"
7
6
  require "active_support/descendants_tracker"
8
7
  require "active_support/core_ext/module/anonymous"
9
8
  require "active_support/core_ext/module/attr_internal"
@@ -47,7 +46,7 @@ module AbstractController
47
46
  # Returns the formats that can be processed by the controller.
48
47
  attr_internal :formats
49
48
 
50
- include ActiveSupport::Configurable
49
+ class_attribute :config, instance_predicate: false, default: ActiveSupport::OrderedOptions.new
51
50
  extend ActiveSupport::DescendantsTracker
52
51
 
53
52
  class << self
@@ -65,6 +64,7 @@ module AbstractController
65
64
  unless klass.instance_variable_defined?(:@abstract)
66
65
  klass.instance_variable_set(:@abstract, false)
67
66
  end
67
+ klass.config = ActiveSupport::InheritableOptions.new(config)
68
68
  super
69
69
  end
70
70
 
@@ -97,7 +97,8 @@ module AbstractController
97
97
  methods = public_instance_methods(true) - internal_methods
98
98
  # Be sure to include shadowed public instance methods of this class.
99
99
  methods.concat(public_instance_methods(false))
100
- methods.map!(&:to_s)
100
+ methods.reject! { |m| m.start_with?("_") }
101
+ methods.map!(&:name)
101
102
  methods.to_set
102
103
  end
103
104
  end
@@ -121,6 +122,10 @@ module AbstractController
121
122
  @controller_path ||= name.delete_suffix("Controller").underscore unless anonymous?
122
123
  end
123
124
 
125
+ def configure # :nodoc:
126
+ yield config
127
+ end
128
+
124
129
  # Refresh the cached action_methods when a new action_method is added.
125
130
  def method_added(name)
126
131
  super
@@ -190,6 +195,10 @@ module AbstractController
190
195
  true
191
196
  end
192
197
 
198
+ def config # :nodoc:
199
+ @_config ||= self.class.config.inheritable_copy
200
+ end
201
+
193
202
  def inspect # :nodoc:
194
203
  "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
195
204
  end
@@ -32,13 +32,16 @@ module AbstractController
32
32
  included do
33
33
  extend ConfigMethods
34
34
 
35
- config_accessor :default_static_extension
35
+ singleton_class.delegate :default_static_extension, :default_static_extension=, to: :config
36
+ delegate :default_static_extension, :default_static_extension=, to: :config
36
37
  self.default_static_extension ||= ".html"
37
38
 
38
- config_accessor :perform_caching
39
+ singleton_class.delegate :perform_caching, :perform_caching=, to: :config
40
+ delegate :perform_caching, :perform_caching=, to: :config
39
41
  self.perform_caching = true if perform_caching.nil?
40
42
 
41
- config_accessor :enable_fragment_cache_logging
43
+ singleton_class.delegate :enable_fragment_cache_logging, :enable_fragment_cache_logging=, to: :config
44
+ delegate :enable_fragment_cache_logging, :enable_fragment_cache_logging=, to: :config
42
45
  self.enable_fragment_cache_logging = false
43
46
 
44
47
  class_attribute :_view_cache_dependencies, default: []
@@ -90,7 +90,7 @@ module AbstractController
90
90
  #--
91
91
  # Implemented by Resolution#modules_for_helpers.
92
92
 
93
- # :method: # all_helpers_from_path
93
+ # :method: all_helpers_from_path
94
94
  # :call-seq: all_helpers_from_path(path)
95
95
  #
96
96
  # Returns a list of helper names in a given path.
@@ -9,7 +9,8 @@ module AbstractController
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  included do
12
- config_accessor :logger
12
+ singleton_class.delegate :logger, :logger=, to: :config
13
+ delegate :logger, :logger=, to: :config
13
14
  include ActiveSupport::Benchmarkable
14
15
  end
15
16
  end
@@ -5,6 +5,7 @@
5
5
  require "action_view"
6
6
  require "action_controller"
7
7
  require "action_controller/log_subscriber"
8
+ require "action_controller/structured_event_subscriber"
8
9
 
9
10
  module ActionController
10
11
  # # Action Controller API
@@ -4,6 +4,7 @@
4
4
 
5
5
  require "action_view"
6
6
  require "action_controller/log_subscriber"
7
+ require "action_controller/structured_event_subscriber"
7
8
  require "action_controller/metal/params_wrapper"
8
9
 
9
10
  module ActionController
@@ -128,7 +129,7 @@ module ActionController
128
129
  #
129
130
  # Action Controller sends content to the user by using one of five rendering
130
131
  # methods. The most versatile and common is the rendering of a template.
131
- # Included in the Action Pack is the Action View, which enables rendering of ERB
132
+ # Also included with \Rails is Action View, which enables rendering of ERB
132
133
  # templates. It's automatically configured. The controller passes objects to the
133
134
  # view by assigning instance variables:
134
135
  #
@@ -9,9 +9,8 @@ module ActionController
9
9
  # of calculations, renderings, and database calls around for subsequent
10
10
  # requests.
11
11
  #
12
- # You can read more about each approach by clicking the modules below.
13
- #
14
12
  # Note: To turn off all caching provided by Action Controller, set
13
+ #
15
14
  # config.action_controller.perform_caching = false
16
15
  #
17
16
  # ## Caching stores
@@ -40,7 +40,7 @@ module ActionController
40
40
  # rendered by this controller and its subclasses.
41
41
  #
42
42
  # #### Parameters
43
- # * `builder` - Default form builder, an instance of
43
+ # * `builder` - Default form builder. Accepts a subclass of
44
44
  # ActionView::Helpers::FormBuilder
45
45
  def default_form_builder(builder)
46
46
  self._default_form_builder = builder
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :markup: markdown
4
-
5
3
  module ActionController
6
- class LogSubscriber < ActiveSupport::LogSubscriber
4
+ class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc:
7
5
  INTERNAL_PARAMS = %w(controller action format _method only_path)
8
6
 
7
+ class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new
8
+
9
9
  def start_processing(event)
10
10
  return unless logger.info?
11
11
 
@@ -49,6 +49,13 @@ module ActionController
49
49
  end
50
50
  subscribe_log_level :halted_callback, :info
51
51
 
52
+ # Manually subscribed below
53
+ def rescue_from_callback(event)
54
+ exception = event.payload[:exception]
55
+ info { "rescue_from handled #{exception.class} (#{exception.message}) - #{exception.backtrace.first.delete_prefix("#{Rails.root}/")}" }
56
+ end
57
+ subscribe_log_level :rescue_from_callback, :info
58
+
52
59
  def send_file(event)
53
60
  info { "Sent file #{event.payload[:path]} (#{event.duration.round(1)}ms)" }
54
61
  end
@@ -56,6 +63,10 @@ module ActionController
56
63
 
57
64
  def redirect_to(event)
58
65
  info { "Redirected to #{event.payload[:location]}" }
66
+
67
+ if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location)
68
+ info { "↳ #{source}" }
69
+ end
59
70
  end
60
71
  subscribe_log_level :redirect_to, :info
61
72
 
@@ -90,6 +101,10 @@ module ActionController
90
101
  def logger
91
102
  ActionController::Base.logger
92
103
  end
104
+
105
+ def redirect_source_location
106
+ backtrace_cleaner.first_clean_frame
107
+ end
93
108
  end
94
109
  end
95
110
 
@@ -14,7 +14,7 @@ module ActionController # :nodoc:
14
14
  # aren't reporting a user-agent header, will be allowed access.
15
15
  #
16
16
  # A browser that's blocked will by default be served the file in
17
- # public/406-unsupported-browser.html with a HTTP status code of "406 Not
17
+ # public/406-unsupported-browser.html with an HTTP status code of "406 Not
18
18
  # Acceptable".
19
19
  #
20
20
  # In addition to specifically named browser versions, you can also pass
@@ -332,6 +332,31 @@ module ActionController
332
332
  response.cache_control.replace(no_store: true)
333
333
  end
334
334
 
335
+ # Adds the `must-understand` directive to the `Cache-Control` header, which indicates
336
+ # that a cache MUST understand the semantics of the response status code that has been
337
+ # received, or discard the response.
338
+ #
339
+ # This is particularly useful when returning responses with new or uncommon
340
+ # status codes that might not be properly interpreted by older caches.
341
+ #
342
+ # #### Example
343
+ #
344
+ # def show
345
+ # @article = Article.find(params[:id])
346
+ #
347
+ # if @article.early_access?
348
+ # must_understand
349
+ # render status: 203 # Non-Authoritative Information
350
+ # else
351
+ # fresh_when @article
352
+ # end
353
+ # end
354
+ #
355
+ def must_understand
356
+ response.cache_control[:must_understand] = true
357
+ response.cache_control[:no_store] = true
358
+ end
359
+
335
360
  private
336
361
  def combine_etags(validator, options)
337
362
  [validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact
@@ -134,9 +134,7 @@ module ActionController # :nodoc:
134
134
  raise ArgumentError, ":type option required" if content_type.nil?
135
135
 
136
136
  if content_type.is_a?(Symbol)
137
- extension = Mime[content_type]
138
- raise ArgumentError, "Unknown MIME type #{options[:type]}" unless extension
139
- self.content_type = extension
137
+ self.content_type = content_type
140
138
  else
141
139
  if !type_provided && options[:filename]
142
140
  # If type wasn't provided, try guessing from file extension.
@@ -103,4 +103,9 @@ module ActionController
103
103
  super(message)
104
104
  end
105
105
  end
106
+
107
+ # Raised when a Rate Limit is exceeded by too many requests within a period of
108
+ # time.
109
+ class TooManyRequests < ActionControllerError
110
+ end
106
111
  end
@@ -38,15 +38,12 @@ module ActionController # :nodoc:
38
38
  define_method(type) do
39
39
  request.flash[type]
40
40
  end
41
+ private type
41
42
  helper_method(type) if respond_to?(:helper_method)
42
43
 
43
44
  self._flash_types += [type]
44
45
  end
45
46
  end
46
-
47
- def action_methods # :nodoc:
48
- @action_methods ||= super - _flash_types.map(&:to_s).to_set
49
- end
50
47
  end
51
48
 
52
49
  private
@@ -25,6 +25,8 @@ module ActionController
25
25
  raise ArgumentError, "#{status.inspect} is not a valid value for `status`."
26
26
  end
27
27
 
28
+ raise ::AbstractController::DoubleRenderError if response_body
29
+
28
30
  status ||= :ok
29
31
 
30
32
  if options
@@ -41,7 +43,7 @@ module ActionController
41
43
 
42
44
  if include_content?(response_code)
43
45
  unless self.media_type
44
- self.content_type = content_type || ((f = formats) && Mime[f.first]) || Mime[:html]
46
+ self.content_type = content_type || ((f = formats) && Mime[f.first]) || :html
45
47
  end
46
48
 
47
49
  response.charset = false
@@ -133,15 +133,16 @@ module ActionController
133
133
  private
134
134
  def perform_write(json, options)
135
135
  current_options = @options.merge(options).stringify_keys
136
-
136
+ event = +""
137
137
  PERMITTED_OPTIONS.each do |option_name|
138
138
  if (option_value = current_options[option_name])
139
- @stream.write "#{option_name}: #{option_value}\n"
139
+ event << "#{option_name}: #{option_value}\n"
140
140
  end
141
141
  end
142
142
 
143
143
  message = json.gsub("\n", "\ndata: ")
144
- @stream.write "data: #{message}\n\n"
144
+ event << "data: #{message}\n\n"
145
+ @stream.write event
145
146
  end
146
147
  end
147
148
 
@@ -236,12 +237,7 @@ module ActionController
236
237
 
237
238
  private
238
239
  def each_chunk(&block)
239
- loop do
240
- str = nil
241
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
242
- str = @buf.pop
243
- end
244
- break unless str
240
+ while str = @buf.pop
245
241
  yield str
246
242
  end
247
243
  end
@@ -275,16 +271,14 @@ module ActionController
275
271
  # This processes the action in a child thread. It lets us return the response
276
272
  # code and headers back up the Rack stack, and still process the body in
277
273
  # parallel with sending data to the client.
278
- new_controller_thread {
274
+ new_controller_thread do
279
275
  ActiveSupport::Dependencies.interlock.running do
280
276
  t2 = Thread.current
281
277
 
282
278
  # Since we're processing the view in a different thread, copy the thread locals
283
279
  # from the main thread to the child thread. :'(
284
280
  locals.each { |k, v| t2[k] = v }
285
- ActiveSupport::IsolatedExecutionState.share_with(t1)
286
-
287
- begin
281
+ ActiveSupport::IsolatedExecutionState.share_with(t1) do
288
282
  super(name)
289
283
  rescue => e
290
284
  if @_response.committed?
@@ -301,18 +295,15 @@ module ActionController
301
295
  error = e
302
296
  end
303
297
  ensure
304
- ActiveSupport::IsolatedExecutionState.clear
305
298
  clean_up_thread_locals(locals, t2)
306
299
 
307
300
  @_response.commit!
308
301
  end
309
302
  end
310
- }
311
-
312
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
313
- @_response.await_commit
314
303
  end
315
304
 
305
+ @_response.await_commit
306
+
316
307
  raise error if error
317
308
  end
318
309
 
@@ -24,8 +24,17 @@ module ActionController # :nodoc:
24
24
  # end
25
25
  # end
26
26
  #
27
+ # Requires a global policy defined in an initializer, which can be
28
+ # empty:
29
+ #
30
+ # Rails.application.config.permissions_policy do |policy|
31
+ # # policy.gyroscope :none
32
+ # end
27
33
  def permissions_policy(**options, &block)
28
34
  before_action(options) do
35
+ unless request.respond_to?(:permissions_policy)
36
+ raise "Cannot override permissions_policy if no global permissions_policy configured."
37
+ end
29
38
  if block_given?
30
39
  policy = request.permissions_policy.clone
31
40
  instance_exec(policy, &block)
@@ -18,8 +18,13 @@ module ActionController # :nodoc:
18
18
  # parameter. It's evaluated within the context of the controller processing the
19
19
  # request.
20
20
  #
21
- # Requests that exceed the rate limit are refused with a `429 Too Many Requests`
22
- # response. You can specialize this by passing a callable in the `with:`
21
+ # By default, rate limits are scoped to the controller's path. If you want to
22
+ # share rate limits across multiple controllers, you can provide your own scope,
23
+ # by passing value in the `scope:` parameter.
24
+ #
25
+ # Requests that exceed the rate limit will raise an `ActionController::TooManyRequests`
26
+ # error. By default, Action Dispatch will rescue from the error and refuse the request
27
+ # with a `429 Too Many Requests` response. You can specialize this by passing a callable in the `with:`
23
28
  # parameter. It's evaluated within the context of the controller processing the
24
29
  # request.
25
30
  #
@@ -40,30 +45,46 @@ module ActionController # :nodoc:
40
45
  #
41
46
  # class SignupsController < ApplicationController
42
47
  # rate_limit to: 1000, within: 10.seconds,
43
- # by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new
48
+ # by: -> { request.domain }, with: :redirect_to_busy, only: :new
49
+ #
50
+ # private
51
+ # def redirect_to_busy
52
+ # redirect_to busy_controller_url, alert: "Too many signups on domain!"
53
+ # end
44
54
  # end
45
55
  #
46
56
  # class APIController < ApplicationController
47
57
  # RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
48
58
  # rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE
59
+ # rate_limit to: 100, within: 5.minutes, scope: :api_global
49
60
  # end
50
61
  #
51
62
  # class SessionsController < ApplicationController
52
63
  # rate_limit to: 3, within: 2.seconds, name: "short-term"
53
64
  # rate_limit to: 10, within: 5.minutes, name: "long-term"
54
65
  # end
55
- def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: cache_store, name: nil, **options)
56
- before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name) }, **options
66
+ def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests }, store: cache_store, name: nil, scope: nil, **options)
67
+ before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name, scope: scope || controller_path) }, **options
57
68
  end
58
69
  end
59
70
 
60
71
  private
61
- def rate_limiting(to:, within:, by:, with:, store:, name:)
62
- cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].compact.join(":")
72
+ def rate_limiting(to:, within:, by:, with:, store:, name:, scope:)
73
+ by = by.is_a?(Symbol) ? send(by) : instance_exec(&by)
74
+
75
+ cache_key = ["rate-limit", scope, name, by].compact.join(":")
63
76
  count = store.increment(cache_key, 1, expires_in: within)
64
77
  if count && count > to
65
- ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do
66
- instance_exec(&with)
78
+ ActiveSupport::Notifications.instrument("rate_limit.action_controller",
79
+ request: request,
80
+ count: count,
81
+ to: to,
82
+ within: within,
83
+ by: by,
84
+ name: name,
85
+ scope: scope,
86
+ cache_key: cache_key) do
87
+ with.is_a?(Symbol) ? send(with) : instance_exec(&with)
67
88
  end
68
89
  end
69
90
  end
@@ -11,10 +11,36 @@ module ActionController
11
11
 
12
12
  class UnsafeRedirectError < StandardError; end
13
13
 
14
+ class OpenRedirectError < UnsafeRedirectError
15
+ def initialize(location)
16
+ super("Unsafe redirect to #{location.to_s.truncate(100).inspect}, pass allow_other_host: true to redirect anyway.")
17
+ end
18
+ end
19
+
20
+ class PathRelativeRedirectError < UnsafeRedirectError
21
+ def initialize(url)
22
+ super("Path relative URL redirect detected: #{url.inspect}")
23
+ end
24
+ end
25
+
14
26
  ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
15
27
 
16
28
  included do
17
29
  mattr_accessor :raise_on_open_redirects, default: false
30
+ mattr_accessor :action_on_open_redirect, default: :log
31
+ mattr_accessor :action_on_path_relative_redirect, default: :log
32
+ class_attribute :_allowed_redirect_hosts, :allowed_redirect_hosts_permissions, instance_accessor: false, instance_predicate: false
33
+ singleton_class.alias_method :allowed_redirect_hosts, :_allowed_redirect_hosts
34
+ end
35
+
36
+ module ClassMethods # :nodoc:
37
+ def allowed_redirect_hosts=(hosts)
38
+ hosts = hosts.dup.freeze
39
+ self._allowed_redirect_hosts = hosts
40
+ self.allowed_redirect_hosts_permissions = if hosts.present?
41
+ ActionDispatch::HostAuthorization::Permissions.new(hosts)
42
+ end
43
+ end
18
44
  end
19
45
 
20
46
  # Redirects the browser to the target specified in `options`. This parameter can
@@ -82,16 +108,17 @@ module ActionController
82
108
  # ### Open Redirect protection
83
109
  #
84
110
  # By default, Rails protects against redirecting to external hosts for your
85
- # app's safety, so called open redirects. Note: this was a new default in Rails
86
- # 7.0, after upgrading opt-in by uncommenting the line with
87
- # `raise_on_open_redirects` in
88
- # `config/initializers/new_framework_defaults_7_0.rb`
111
+ # app's safety, so called open redirects.
89
112
  #
90
113
  # Here #redirect_to automatically validates the potentially-unsafe URL:
91
114
  #
92
115
  # redirect_to params[:redirect_url]
93
116
  #
94
- # Raises UnsafeRedirectError in the case of an unsafe redirect.
117
+ # The `action_on_open_redirect` configuration option controls the behavior when an unsafe
118
+ # redirect is detected:
119
+ # * `:log` - Logs a warning but allows the redirect
120
+ # * `:notify` - Sends an ActiveSupport notification for monitoring
121
+ # * `:raise` - Raises an UnsafeRedirectError
95
122
  #
96
123
  # To allow any external redirects pass `allow_other_host: true`, though using a
97
124
  # user-provided param in that case is unsafe.
@@ -100,11 +127,31 @@ module ActionController
100
127
  #
101
128
  # See #url_from for more information on what an internal and safe URL is, or how
102
129
  # to fall back to an alternate redirect URL in the unsafe case.
130
+ #
131
+ # ### Path Relative URL Redirect Protection
132
+ #
133
+ # Rails also protects against potentially unsafe path relative URL redirects that don't
134
+ # start with a leading slash. These can create security vulnerabilities:
135
+ #
136
+ # redirect_to "example.com" # Creates http://yourdomain.comexample.com
137
+ # redirect_to "@attacker.com" # Creates http://yourdomain.com@attacker.com
138
+ # # which browsers interpret as user@host
139
+ #
140
+ # You can configure how Rails handles these cases using:
141
+ #
142
+ # config.action_controller.action_on_path_relative_redirect = :log # default
143
+ # config.action_controller.action_on_path_relative_redirect = :notify
144
+ # config.action_controller.action_on_path_relative_redirect = :raise
145
+ #
146
+ # * `:log` - Logs a warning but allows the redirect
147
+ # * `:notify` - Sends an ActiveSupport notification but allows the redirect
148
+ # (includes stack trace to help identify the source)
149
+ # * `:raise` - Raises an UnsafeRedirectError
103
150
  def redirect_to(options = {}, response_options = {})
104
151
  raise ActionControllerError.new("Cannot redirect to nil!") unless options
105
152
  raise AbstractController::DoubleRenderError if response_body
106
153
 
107
- allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host }
154
+ allow_other_host = response_options.delete(:allow_other_host)
108
155
 
109
156
  proposed_status = _extract_redirect_to_status(options, response_options)
110
157
 
@@ -166,6 +213,10 @@ module ActionController
166
213
  when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i
167
214
  options.to_str
168
215
  when String
216
+ if !options.start_with?("/", "?") && !options.empty?
217
+ _handle_path_relative_redirect(options)
218
+ end
219
+
169
220
  request.protocol + request.host_with_port + options
170
221
  when Proc
171
222
  _compute_redirect_to_location request, instance_eval(&options)
@@ -207,7 +258,9 @@ module ActionController
207
258
 
208
259
  private
209
260
  def _allow_other_host
210
- !raise_on_open_redirects
261
+ return false if raise_on_open_redirects
262
+
263
+ action_on_open_redirect != :raise
211
264
  end
212
265
 
213
266
  def _extract_redirect_to_status(options, response_options)
@@ -221,20 +274,42 @@ module ActionController
221
274
  end
222
275
 
223
276
  def _enforce_open_redirect_protection(location, allow_other_host:)
277
+ # Explictly allowed other host or host is in allow list allow redirect
224
278
  if allow_other_host || _url_host_allowed?(location)
225
279
  location
280
+ # Explicitly disallowed other host
281
+ elsif allow_other_host == false
282
+ raise OpenRedirectError.new(location)
283
+ # Configuration disallows other hosts
284
+ elsif !_allow_other_host
285
+ raise OpenRedirectError.new(location)
286
+ # Log but allow redirect
287
+ elsif action_on_open_redirect == :log
288
+ logger.warn "Open redirect to #{location.inspect} detected" if logger
289
+ location
290
+ # Notify but allow redirect
291
+ elsif action_on_open_redirect == :notify
292
+ ActiveSupport::Notifications.instrument("open_redirect.action_controller",
293
+ location: location,
294
+ request: request,
295
+ stack_trace: caller,
296
+ )
297
+ location
298
+ # Fall through, should not happen but raise for safety
226
299
  else
227
- raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway."
300
+ raise OpenRedirectError.new(location)
228
301
  end
229
302
  end
230
303
 
231
304
  def _url_host_allowed?(url)
232
- host = URI(url.to_s).host
305
+ url_to_s = url.to_s
306
+ host = URI(url_to_s).host
233
307
 
234
- return true if host == request.host
235
- return false unless host.nil?
236
- return false unless url.to_s.start_with?("/")
237
- !url.to_s.start_with?("//")
308
+ if host.nil?
309
+ url_to_s.start_with?("/") && !url_to_s.start_with?("//")
310
+ else
311
+ host == request.host || self.class.allowed_redirect_hosts_permissions&.allows?(host)
312
+ end
238
313
  rescue ArgumentError, URI::Error
239
314
  false
240
315
  end
@@ -248,5 +323,22 @@ module ActionController
248
323
  raise UnsafeRedirectError, msg
249
324
  end
250
325
  end
326
+
327
+ def _handle_path_relative_redirect(url)
328
+ message = "Path relative URL redirect detected: #{url.inspect}"
329
+
330
+ case action_on_path_relative_redirect
331
+ when :log
332
+ logger&.warn message
333
+ when :notify
334
+ ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller",
335
+ url: url,
336
+ message: message,
337
+ stack_trace: caller
338
+ )
339
+ when :raise
340
+ raise PathRelativeRedirectError.new(url)
341
+ end
342
+ end
251
343
  end
252
344
  end