actionpack 8.0.3 → 8.1.0.rc1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +335 -158
- data/lib/abstract_controller/asset_paths.rb +4 -2
- data/lib/abstract_controller/base.rb +12 -3
- data/lib/abstract_controller/caching.rb +6 -3
- data/lib/abstract_controller/logger.rb +2 -1
- data/lib/action_controller/api.rb +1 -0
- data/lib/action_controller/base.rb +2 -1
- data/lib/action_controller/caching.rb +1 -2
- data/lib/action_controller/form_builder.rb +1 -1
- data/lib/action_controller/log_subscriber.rb +18 -3
- data/lib/action_controller/metal/allow_browser.rb +1 -1
- data/lib/action_controller/metal/conditional_get.rb +25 -0
- data/lib/action_controller/metal/data_streaming.rb +1 -3
- data/lib/action_controller/metal/exceptions.rb +5 -0
- data/lib/action_controller/metal/flash.rb +1 -4
- data/lib/action_controller/metal/head.rb +3 -1
- data/lib/action_controller/metal/live.rb +9 -18
- data/lib/action_controller/metal/permissions_policy.rb +9 -0
- data/lib/action_controller/metal/rate_limiting.rb +30 -9
- data/lib/action_controller/metal/redirecting.rb +104 -9
- data/lib/action_controller/metal/renderers.rb +27 -6
- data/lib/action_controller/metal/rendering.rb +7 -1
- data/lib/action_controller/metal/request_forgery_protection.rb +18 -10
- data/lib/action_controller/metal/rescue.rb +9 -0
- data/lib/action_controller/railtie.rb +27 -8
- data/lib/action_controller/structured_event_subscriber.rb +107 -0
- data/lib/action_dispatch/http/cache.rb +111 -1
- data/lib/action_dispatch/http/filter_parameters.rb +5 -3
- data/lib/action_dispatch/http/mime_negotiation.rb +55 -1
- data/lib/action_dispatch/http/mime_types.rb +1 -0
- data/lib/action_dispatch/http/param_builder.rb +28 -27
- data/lib/action_dispatch/http/parameters.rb +3 -3
- data/lib/action_dispatch/http/permissions_policy.rb +4 -0
- data/lib/action_dispatch/http/query_parser.rb +12 -10
- data/lib/action_dispatch/http/request.rb +10 -5
- data/lib/action_dispatch/http/response.rb +16 -3
- data/lib/action_dispatch/http/url.rb +110 -14
- data/lib/action_dispatch/journey/gtg/simulator.rb +33 -12
- data/lib/action_dispatch/journey/gtg/transition_table.rb +33 -41
- data/lib/action_dispatch/journey/nodes/node.rb +2 -1
- data/lib/action_dispatch/journey/route.rb +45 -31
- data/lib/action_dispatch/journey/router/utils.rb +8 -14
- data/lib/action_dispatch/journey/router.rb +59 -81
- data/lib/action_dispatch/journey/routes.rb +7 -0
- data/lib/action_dispatch/journey/visitors.rb +55 -23
- data/lib/action_dispatch/journey/visualizer/fsm.js +4 -6
- data/lib/action_dispatch/log_subscriber.rb +7 -3
- data/lib/action_dispatch/middleware/cookies.rb +4 -2
- data/lib/action_dispatch/middleware/debug_exceptions.rb +7 -1
- data/lib/action_dispatch/middleware/debug_view.rb +11 -0
- data/lib/action_dispatch/middleware/exception_wrapper.rb +11 -5
- data/lib/action_dispatch/middleware/executor.rb +12 -2
- data/lib/action_dispatch/middleware/public_exceptions.rb +1 -5
- data/lib/action_dispatch/middleware/remote_ip.rb +9 -4
- data/lib/action_dispatch/middleware/session/cache_store.rb +17 -0
- data/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +3 -2
- data/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +9 -5
- data/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +4 -0
- data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +3 -0
- data/lib/action_dispatch/middleware/templates/rescues/layout.erb +50 -0
- data/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +1 -0
- data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -0
- data/lib/action_dispatch/railtie.rb +14 -2
- data/lib/action_dispatch/routing/inspector.rb +79 -56
- data/lib/action_dispatch/routing/mapper.rb +323 -173
- data/lib/action_dispatch/routing/redirection.rb +10 -7
- data/lib/action_dispatch/routing/route_set.rb +2 -4
- data/lib/action_dispatch/structured_event_subscriber.rb +20 -0
- data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +2 -2
- data/lib/action_dispatch/testing/assertions/response.rb +14 -0
- data/lib/action_dispatch/testing/assertions/routing.rb +11 -3
- data/lib/action_dispatch/testing/integration.rb +1 -1
- data/lib/action_dispatch.rb +8 -0
- data/lib/action_pack/gem_version.rb +3 -3
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: []
|
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
@@ -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]) ||
|
|
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
|
-
|
|
139
|
+
event << "#{option_name}: #{option_value}\n"
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
message = json.gsub("\n", "\ndata: ")
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
22
|
-
#
|
|
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:
|
|
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: -> {
|
|
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
|
-
|
|
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",
|
|
66
|
-
|
|
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
|
|
@@ -91,7 +117,11 @@ module ActionController
|
|
|
91
117
|
#
|
|
92
118
|
# redirect_to params[:redirect_url]
|
|
93
119
|
#
|
|
94
|
-
#
|
|
120
|
+
# The `action_on_open_redirect` configuration option controls the behavior when an unsafe
|
|
121
|
+
# redirect is detected:
|
|
122
|
+
# * `:log` - Logs a warning but allows the redirect
|
|
123
|
+
# * `:notify` - Sends an ActiveSupport notification for monitoring
|
|
124
|
+
# * `:raise` - Raises an UnsafeRedirectError
|
|
95
125
|
#
|
|
96
126
|
# To allow any external redirects pass `allow_other_host: true`, though using a
|
|
97
127
|
# user-provided param in that case is unsafe.
|
|
@@ -100,11 +130,31 @@ module ActionController
|
|
|
100
130
|
#
|
|
101
131
|
# See #url_from for more information on what an internal and safe URL is, or how
|
|
102
132
|
# to fall back to an alternate redirect URL in the unsafe case.
|
|
133
|
+
#
|
|
134
|
+
# ### Path Relative URL Redirect Protection
|
|
135
|
+
#
|
|
136
|
+
# Rails also protects against potentially unsafe path relative URL redirects that don't
|
|
137
|
+
# start with a leading slash. These can create security vulnerabilities:
|
|
138
|
+
#
|
|
139
|
+
# redirect_to "example.com" # Creates http://yourdomain.comexample.com
|
|
140
|
+
# redirect_to "@attacker.com" # Creates http://yourdomain.com@attacker.com
|
|
141
|
+
# # which browsers interpret as user@host
|
|
142
|
+
#
|
|
143
|
+
# You can configure how Rails handles these cases using:
|
|
144
|
+
#
|
|
145
|
+
# config.action_controller.action_on_path_relative_redirect = :log # default
|
|
146
|
+
# config.action_controller.action_on_path_relative_redirect = :notify
|
|
147
|
+
# config.action_controller.action_on_path_relative_redirect = :raise
|
|
148
|
+
#
|
|
149
|
+
# * `:log` - Logs a warning but allows the redirect
|
|
150
|
+
# * `:notify` - Sends an ActiveSupport notification but allows the redirect
|
|
151
|
+
# (includes stack trace to help identify the source)
|
|
152
|
+
# * `:raise` - Raises an UnsafeRedirectError
|
|
103
153
|
def redirect_to(options = {}, response_options = {})
|
|
104
154
|
raise ActionControllerError.new("Cannot redirect to nil!") unless options
|
|
105
155
|
raise AbstractController::DoubleRenderError if response_body
|
|
106
156
|
|
|
107
|
-
allow_other_host = response_options.delete(:allow_other_host)
|
|
157
|
+
allow_other_host = response_options.delete(:allow_other_host)
|
|
108
158
|
|
|
109
159
|
proposed_status = _extract_redirect_to_status(options, response_options)
|
|
110
160
|
|
|
@@ -166,6 +216,10 @@ module ActionController
|
|
|
166
216
|
when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i
|
|
167
217
|
options.to_str
|
|
168
218
|
when String
|
|
219
|
+
if !options.start_with?("/", "?") && !options.empty?
|
|
220
|
+
_handle_path_relative_redirect(options)
|
|
221
|
+
end
|
|
222
|
+
|
|
169
223
|
request.protocol + request.host_with_port + options
|
|
170
224
|
when Proc
|
|
171
225
|
_compute_redirect_to_location request, instance_eval(&options)
|
|
@@ -207,7 +261,9 @@ module ActionController
|
|
|
207
261
|
|
|
208
262
|
private
|
|
209
263
|
def _allow_other_host
|
|
210
|
-
|
|
264
|
+
return false if raise_on_open_redirects
|
|
265
|
+
|
|
266
|
+
action_on_open_redirect != :raise
|
|
211
267
|
end
|
|
212
268
|
|
|
213
269
|
def _extract_redirect_to_status(options, response_options)
|
|
@@ -221,20 +277,42 @@ module ActionController
|
|
|
221
277
|
end
|
|
222
278
|
|
|
223
279
|
def _enforce_open_redirect_protection(location, allow_other_host:)
|
|
280
|
+
# Explictly allowed other host or host is in allow list allow redirect
|
|
224
281
|
if allow_other_host || _url_host_allowed?(location)
|
|
225
282
|
location
|
|
283
|
+
# Explicitly disallowed other host
|
|
284
|
+
elsif allow_other_host == false
|
|
285
|
+
raise OpenRedirectError.new(location)
|
|
286
|
+
# Configuration disallows other hosts
|
|
287
|
+
elsif !_allow_other_host
|
|
288
|
+
raise OpenRedirectError.new(location)
|
|
289
|
+
# Log but allow redirect
|
|
290
|
+
elsif action_on_open_redirect == :log
|
|
291
|
+
logger.warn "Open redirect to #{location.inspect} detected" if logger
|
|
292
|
+
location
|
|
293
|
+
# Notify but allow redirect
|
|
294
|
+
elsif action_on_open_redirect == :notify
|
|
295
|
+
ActiveSupport::Notifications.instrument("open_redirect.action_controller",
|
|
296
|
+
location: location,
|
|
297
|
+
request: request,
|
|
298
|
+
stack_trace: caller,
|
|
299
|
+
)
|
|
300
|
+
location
|
|
301
|
+
# Fall through, should not happen but raise for safety
|
|
226
302
|
else
|
|
227
|
-
raise
|
|
303
|
+
raise OpenRedirectError.new(location)
|
|
228
304
|
end
|
|
229
305
|
end
|
|
230
306
|
|
|
231
307
|
def _url_host_allowed?(url)
|
|
232
|
-
|
|
308
|
+
url_to_s = url.to_s
|
|
309
|
+
host = URI(url_to_s).host
|
|
233
310
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
311
|
+
if host.nil?
|
|
312
|
+
url_to_s.start_with?("/") && !url_to_s.start_with?("//")
|
|
313
|
+
else
|
|
314
|
+
host == request.host || self.class.allowed_redirect_hosts_permissions&.allows?(host)
|
|
315
|
+
end
|
|
238
316
|
rescue ArgumentError, URI::Error
|
|
239
317
|
false
|
|
240
318
|
end
|
|
@@ -248,5 +326,22 @@ module ActionController
|
|
|
248
326
|
raise UnsafeRedirectError, msg
|
|
249
327
|
end
|
|
250
328
|
end
|
|
329
|
+
|
|
330
|
+
def _handle_path_relative_redirect(url)
|
|
331
|
+
message = "Path relative URL redirect detected: #{url.inspect}"
|
|
332
|
+
|
|
333
|
+
case action_on_path_relative_redirect
|
|
334
|
+
when :log
|
|
335
|
+
logger&.warn message
|
|
336
|
+
when :notify
|
|
337
|
+
ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller",
|
|
338
|
+
url: url,
|
|
339
|
+
message: message,
|
|
340
|
+
stack_trace: caller
|
|
341
|
+
)
|
|
342
|
+
when :raise
|
|
343
|
+
raise PathRelativeRedirectError.new(url)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
251
346
|
end
|
|
252
347
|
end
|