", text: "foo bar").deliver_now
assert_select_email do
assert_select "div:root" do
assert_select "p:first-child", "foo"
@@ -46,4 +96,60 @@ def test_assert_select_email_multipart
end
end
end
+
+ def test_assert_part_last_mail_delivery
+ AssertMultipartSelectMailer.test(html: "
foo
bar
", text: "foo bar").deliver_now
+
+ assert_part :text do |text|
+ assert_includes text, "foo bar"
+ end
+ assert_part :html do |html|
+ assert_kind_of Rails::Dom::Testing.html_document, html
+
+ assert_dom html, "div" do
+ assert_dom "p:first-child", "foo"
+ assert_dom "p:last-child", "bar"
+ end
+ end
+ end
+
+ def test_assert_part_with_mail_argument
+ mail = AssertMultipartSelectMailer.test(html: "
foo
bar
", text: "foo bar")
+
+ assert_part :text, mail do |text|
+ assert_includes text, "foo bar"
+ end
+ assert_part :html, mail do |html|
+ assert_kind_of Rails::Dom::Testing.html_document, html
+
+ assert_dom html, "div" do
+ assert_dom "p:first-child", "foo"
+ assert_dom "p:last-child", "bar"
+ end
+ end
+ end
+
+ def test_assert_part_without_block
+ assert_part :html, AssertMultipartSelectMailer.test(html: "html")
+ assert_part :text, AssertMultipartSelectMailer.test(text: "text")
+
+ assert_raises Minitest::Assertion, match: "expected part matching text/html" do
+ assert_part :html, AssertMultipartSelectMailer.test(text: "text")
+ end
+ assert_raises Minitest::Assertion, match: "expected part matching text/plain" do
+ assert_part :text, AssertMultipartSelectMailer.test(html: "html")
+ end
+ end
+
+ def test_assert_no_part
+ assert_no_part :html, AssertMultipartSelectMailer.test(text: "text")
+ assert_no_part :text, AssertMultipartSelectMailer.test(html: "html")
+
+ assert_raises Minitest::Assertion, match: "expected no part matching text/html" do
+ assert_no_part :html, AssertMultipartSelectMailer.test(html: "html")
+ end
+ assert_raises Minitest::Assertion, match: "expected no part matching text/plain" do
+ assert_no_part :text, AssertMultipartSelectMailer.test(text: "text")
+ end
+ end
end
diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb
index 49e0cba9fb61a..74e33709ff608 100644
--- a/actionmailer/test/base_test.rb
+++ b/actionmailer/test/base_test.rb
@@ -903,6 +903,8 @@ class FooMailer < ActionMailer::Base
# This triggers action_methods.
respond_to?(:foo)
+ after_deliver :foo
+
def notify
end
end
diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb
index 4272430a9bfd2..89f87a470ca34 100644
--- a/actionmailer/test/log_subscriber_test.rb
+++ b/actionmailer/test/log_subscriber_test.rb
@@ -4,13 +4,26 @@
require "mailers/base_mailer"
require "active_support/log_subscriber/test_helper"
require "action_mailer/log_subscriber"
+require "active_support/testing/event_reporter_assertions"
+require "action_mailer/structured_event_subscriber"
class AMLogSubscriberTest < ActionMailer::TestCase
- include ActiveSupport::LogSubscriber::TestHelper
+ include ActiveSupport::Testing::EventReporterAssertions
- def setup
- super
- ActionMailer::LogSubscriber.attach_to :action_mailer
+ setup do
+ @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new
+ @old_logger = ActionMailer::LogSubscriber.logger
+ ActionMailer::LogSubscriber.logger = @logger
+ end
+
+ teardown do
+ ActionMailer::LogSubscriber.logger = @old_logger
+ end
+
+ def run(*)
+ with_debug_event_reporting do
+ super
+ end
end
class BogusDelivery
@@ -22,13 +35,8 @@ def deliver!(mail)
end
end
- def set_logger(logger)
- ActionMailer::Base.logger = logger
- end
-
def test_deliver_is_notified
BaseMailer.welcome(message_id: "123@abc").deliver_now
- wait
assert_equal(1, @logger.logged(:info).size)
assert_match(/Delivered mail 123@abc/, @logger.logged(:info).first)
@@ -42,7 +50,6 @@ def test_deliver_is_notified
def test_deliver_message_when_perform_deliveries_is_false
BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now
- wait
assert_equal(1, @logger.logged(:info).size)
assert_match("Skipped delivery of mail 123@abc as `perform_deliveries` is false", @logger.logged(:info).first)
@@ -59,7 +66,6 @@ def test_deliver_message_when_exception_happened
BaseMailer.delivery_method = BogusDelivery
assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now }
- wait
assert_equal(1, @logger.logged(:info).size)
assert_equal('Failed delivery of mail 123@abc error_class=RuntimeError error_message="failed"', @logger.logged(:info).first)
diff --git a/actionmailer/test/mail_helper_test.rb b/actionmailer/test/mail_helper_test.rb
index 06729826f8480..b0c74c80561ce 100644
--- a/actionmailer/test/mail_helper_test.rb
+++ b/actionmailer/test/mail_helper_test.rb
@@ -67,6 +67,12 @@ def use_cache
end
end
+ def use_stylesheet_link_tag
+ mail_with_defaults do |format|
+ format.html { render(inline: "<%= stylesheet_link_tag 'mailer' %>") }
+ end
+ end
+
private
def mail_with_defaults(&block)
mail(to: "test@localhost", from: "tester@example.com",
@@ -122,6 +128,18 @@ def test_use_cache
end
end
+ def test_stylesheet_link_tag_without_nonce_method
+ original_auto_include_nonce_for_styles = ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_styles
+ ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_styles = true
+
+ mail = HelperMailer.use_stylesheet_link_tag
+
+ assert_includes mail.body.encoded, %( 0
+ ensure
+ BaseMailer.deliveries.clear
+ end
+
+ def test_deliver_message_when_perform_deliveries_is_false
+ assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/, perform_deliveries: false }) do
+ BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now
+ end
+ ensure
+ BaseMailer.deliveries.clear
+ end
+
+ def test_deliver_message_when_exception_happened
+ previous_delivery_method = BaseMailer.delivery_method
+ BaseMailer.delivery_method = BogusDelivery
+ payload = { message_id: "123@abc", mail: /.*/, exception_class: "RuntimeError", exception_message: "failed" }
+
+ assert_event_reported("action_mailer.delivered", payload:) do
+ assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now }
+ end
+ ensure
+ BaseMailer.delivery_method = previous_delivery_method
+ end
+ end
+end
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md
index 622f59cfb8534..029ebc4aca120 100644
--- a/actionpack/CHANGELOG.md
+++ b/actionpack/CHANGELOG.md
@@ -1,83 +1,58 @@
-* Raise `AbstractController::DoubleRenderError` if `head` is called after rendering.
+* Support `text/markdown` format in `DebugExceptions` middleware.
- After this change, invoking `head` will lead to an error if response body is already set:
+ When `text/markdown` is requested via the Accept header, error responses
+ are returned with `Content-Type: text/markdown` instead of HTML.
+ The existing text templates are reused for markdown output, allowing
+ CLI tools and other clients to receive byte-efficient error information.
- ```ruby
- class PostController < ApplicationController
- def index
- render locals: {}
- head :ok
- end
- end
- ```
-
- *Iaroslav Kurbatov*
+ *Guillermo Iguaran*
-* The Cookie Serializer can now serialize an Active Support SafeBuffer when using message pack.
+* Support dynamic `to:` and `within:` options in `rate_limit`.
- Such code would previously produce an error if an application was using messagepack as its cookie serializer.
+ The `to:` and `within:` options now accept callables (lambdas or procs) and
+ method names (as symbols), in addition to static values. This allows for
+ dynamic rate limiting based on user attributes or other runtime conditions.
```ruby
- class PostController < ApplicationController
- def index
- flash.notice = t(:hello_html) # This would try to serialize a SafeBuffer, which was not possible.
- end
- end
- ```
-
- *Edouard Chin*
+ class APIController < ApplicationController
+ rate_limit to: :max_requests, within: :time_window, by: -> { current_user.id }
-* Fix `Rails.application.reload_routes!` from clearing almost all routes.
+ private
+ def max_requests
+ current_user.premium? ? 1000 : 100
+ end
- When calling `Rails.application.reload_routes!` inside a middleware of
- a Rake task, it was possible under certain conditions that all routes would be cleared.
- If ran inside a middleware, this would result in getting a 404 on most page you visit.
- This issue was only happening in development.
-
- *Edouard Chin*
-
-* Add resource name to the `ArgumentError` that's raised when invalid `:only` or `:except` options are given to `#resource` or `#resources`
-
- This makes it easier to locate the source of the problem, especially for routes drawn by gems.
-
- Before:
- ```
- :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar]
- ```
-
- After:
- ```
- Route `resources :products` - :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar]
+ def time_window
+ current_user.premium? ? 1.hour : 1.minute
+ end
+ end
```
- *Jeremy Green*
-
-* Add `check_collisions` option to `ActionDispatch::Session::CacheStore`.
-
- Newly generated session ids use 128 bits of randomness, which is more than
- enough to ensure collisions can't happen, but if you need to harden sessions
- even more, you can enable this option to check in the session store that the id
- is indeed free you can enable that option. This however incurs an extra write
- on session creation.
-
- *Shia*
+ *Murilo Duarte*
-* In ExceptionWrapper, match backtrace lines with built templates more often,
- allowing improved highlighting of errors within do-end blocks in templates.
- Fix for Ruby 3.4 to match new method labels in backtrace.
-
- *Martin Emde*
-
-* Allow setting content type with a symbol of the Mime type.
+* Define `ActionController::Parameters#deconstruct_keys` to support pattern matching
```ruby
- # Before
- response.content_type = "text/html"
+ if params in { search:, page: }
+ Article.search(search).limit(page)
+ else
+ …
+ end
- # After
- response.content_type = :html
+ case (value = params[:string_or_hash_with_nested_key])
+ in String
+ # do something with a String `value`…
+ in { nested_key: }
+ # do something with `nested_key` or `value`
+ else
+ # …
+ end
```
- *Petrik de Heus*
+ *Sean Doyle*
+
+* Submit test requests using `as: :html` with `Content-Type: x-www-form-urlencoded`
+
+ *Sean Doyle*
-Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionpack/CHANGELOG.md) for previous changes.
+Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionpack/CHANGELOG.md) for previous changes.
diff --git a/actionpack/README.rdoc b/actionpack/README.rdoc
index 028f5910d383a..8c209108804d6 100644
--- a/actionpack/README.rdoc
+++ b/actionpack/README.rdoc
@@ -52,6 +52,6 @@ Bug reports for the Ruby on \Rails project can be filed here:
* https://github.com/rails/rails/issues
-Feature requests should be discussed on the rails-core mailing list here:
+Feature requests should be discussed on the rubyonrails-core forum here:
* https://discuss.rubyonrails.org/c/rubyonrails-core
diff --git a/actionpack/Rakefile b/actionpack/Rakefile
index 1ee11d7f1c509..fd087a55a46ea 100644
--- a/actionpack/Rakefile
+++ b/actionpack/Rakefile
@@ -21,11 +21,17 @@ Rake::TestTask.new do |t|
end
namespace :test do
- task :isolated do
+ task isolated: :railties do
test_files.all? do |file|
sh(Gem.ruby, "-w", "-Ilib:test", file)
end || raise("Failures")
end
+
+ task :railties do
+ ["action_dispatch/railtie", "action_controller/railtie"].all? do |railtie|
+ sh(Gem.ruby, "-r", railtie, "-e", "'OK'")
+ end || raise("Failures")
+ end
end
task :lines do
diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb
index c16601d35fa65..622cafa939bdf 100644
--- a/actionpack/lib/abstract_controller/base.rb
+++ b/actionpack/lib/abstract_controller/base.rb
@@ -86,22 +86,16 @@ def internal_methods
controller.public_instance_methods(true) - methods
end
- # A list of method names that should be considered actions. This includes all
+ # A `Set` of method names that should be considered actions. This includes all
# public instance methods on a controller, less any internal methods (see
# internal_methods), adding back in any methods that are internal, but still
# exist on the class itself.
- #
- # #### Returns
- # * `Set` - A set of all methods that should be considered actions.
- #
def action_methods
@action_methods ||= begin
# All public instance methods of this class, including ancestors except for
# public instance methods of Base and its ancestors.
methods = public_instance_methods(true) - internal_methods
- # Be sure to include shadowed public instance methods of this class.
- methods.concat(public_instance_methods(false))
- methods.map!(&:to_s)
+ methods.map!(&:name)
methods.to_set
end
end
@@ -121,9 +115,6 @@ def clear_action_methods!
#
# MyApp::MyPostsController.controller_path # => "my_app/my_posts"
#
- # #### Returns
- # * `String`
- #
def controller_path
@controller_path ||= name.delete_suffix("Controller").underscore unless anonymous?
end
@@ -151,10 +142,6 @@ def eager_load! # :nodoc:
# The actual method that is called is determined by calling #method_for_action.
# If no method can handle the action, then an AbstractController::ActionNotFound
# error is raised.
- #
- # #### Returns
- # * `self`
- #
def process(action, ...)
@_action_name = action.to_s
diff --git a/actionpack/lib/abstract_controller/callbacks.rb b/actionpack/lib/abstract_controller/callbacks.rb
index cba63b2e38634..31bbb1b5ce9cd 100644
--- a/actionpack/lib/abstract_controller/callbacks.rb
+++ b/actionpack/lib/abstract_controller/callbacks.rb
@@ -29,6 +29,8 @@ module Callbacks
# ActiveSupport::Callbacks.
include ActiveSupport::Callbacks
+ DEFAULT_INTERNAL_METHODS = [:_run_process_action_callbacks].freeze # :nodoc:
+
included do
define_callbacks :process_action,
terminator: ->(controller, result_lambda) { result_lambda.call; controller.performed? },
@@ -251,6 +253,10 @@ def _insert_callbacks(callbacks, block = nil)
# *_action is the same as append_*_action
alias_method :"append_#{callback}_action", :"#{callback}_action"
end
+
+ def internal_methods # :nodoc:
+ super.concat(DEFAULT_INTERNAL_METHODS)
+ end
end
private
diff --git a/actionpack/lib/abstract_controller/collector.rb b/actionpack/lib/abstract_controller/collector.rb
index dee0b49708bd5..d9f41d4ef4879 100644
--- a/actionpack/lib/abstract_controller/collector.rb
+++ b/actionpack/lib/abstract_controller/collector.rb
@@ -27,7 +27,7 @@ def #{sym}(...)
def method_missing(symbol, ...)
unless mime_constant = Mime[symbol]
raise NoMethodError, "To respond to a custom format, register it as a MIME type first: " \
- "https://guides.rubyonrails.org/action_controller_overview.html#restful-downloads. " \
+ "https://guides.rubyonrails.org/action_controller_advanced_topics.html#restful-downloads. " \
"If you meant to respond to a variant like :tablet or :phone, not a custom format, " \
"be sure to nest your variant response within a format response: " \
"format.html { |html| html.tablet { ... } }"
diff --git a/actionpack/lib/abstract_controller/helpers.rb b/actionpack/lib/abstract_controller/helpers.rb
index 8be59d05ddf6e..fc807f12a9cfd 100644
--- a/actionpack/lib/abstract_controller/helpers.rb
+++ b/actionpack/lib/abstract_controller/helpers.rb
@@ -90,7 +90,7 @@ def inherited(klass)
#--
# Implemented by Resolution#modules_for_helpers.
- # :method: # all_helpers_from_path
+ # :method: all_helpers_from_path
# :call-seq: all_helpers_from_path(path)
#
# Returns a list of helper names in a given path.
diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb
index b52430467b13f..a44aa2232611c 100644
--- a/actionpack/lib/action_controller/api.rb
+++ b/actionpack/lib/action_controller/api.rb
@@ -5,6 +5,7 @@
require "action_view"
require "action_controller"
require "action_controller/log_subscriber"
+require "action_controller/structured_event_subscriber"
module ActionController
# # Action Controller API
diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb
index 279dbdd71ecb2..99e080b0c5d88 100644
--- a/actionpack/lib/action_controller/base.rb
+++ b/actionpack/lib/action_controller/base.rb
@@ -4,6 +4,7 @@
require "action_view"
require "action_controller/log_subscriber"
+require "action_controller/structured_event_subscriber"
require "action_controller/metal/params_wrapper"
module ActionController
diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb
index 02f8493cb68d6..b926d17660a8d 100644
--- a/actionpack/lib/action_controller/log_subscriber.rb
+++ b/actionpack/lib/action_controller/log_subscriber.rb
@@ -1,15 +1,15 @@
# frozen_string_literal: true
-# :markup: markdown
-
module ActionController
- class LogSubscriber < ActiveSupport::LogSubscriber
+ class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc:
INTERNAL_PARAMS = %w(controller action format _method only_path)
- def start_processing(event)
- return unless logger.info?
+ class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new
+
+ self.namespace = "action_controller"
- payload = event.payload
+ def request_started(event)
+ payload = event[:payload]
params = {}
payload[:params].each_pair do |k, v|
params[k] = v unless INTERNAL_PARAMS.include?(k)
@@ -21,11 +21,11 @@ def start_processing(event)
info "Processing by #{payload[:controller]}##{payload[:action]} as #{format}"
info " Parameters: #{params.inspect}" unless params.empty?
end
- subscribe_log_level :start_processing, :info
+ event_log_level :request_started, :info
- def process_action(event)
+ def request_completed(event)
info do
- payload = event.payload
+ payload = event[:payload]
additions = ActionController::Base.log_process_action(payload)
status = payload[:status]
@@ -33,64 +33,81 @@ def process_action(event)
status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name)
end
- additions << "GC: #{event.gc_time.round(1)}ms"
+ additions << "GC: #{payload[:gc_time_ms].round(1)}ms"
- message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{event.duration.round}ms" \
+ message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{payload[:duration_ms].round(0)}ms" \
" (#{additions.join(" | ")})"
message << "\n\n" if defined?(Rails.env) && Rails.env.development?
message
end
end
- subscribe_log_level :process_action, :info
+ event_log_level :request_completed, :info
- def halted_callback(event)
- info { "Filter chain halted as #{event.payload[:filter].inspect} rendered or redirected" }
+ def callback_halted(event)
+ info { "Filter chain halted as #{event[:payload][:filter].inspect} rendered or redirected" }
+ end
+ event_log_level :callback_halted, :info
+
+ # Manually subscribed below
+ def rescue_from_handled(event)
+ exception_class = event[:payload][:exception_class]
+ exception_message = event[:payload][:exception_message]
+ exception_backtrace = event[:payload][:exception_backtrace]
+ info { "rescue_from handled #{exception_class} (#{exception_message}) - #{exception_backtrace}" }
end
- subscribe_log_level :halted_callback, :info
+ event_log_level :rescue_from_handled, :info
- def send_file(event)
- info { "Sent file #{event.payload[:path]} (#{event.duration.round(1)}ms)" }
+ def file_sent(event)
+ info { "Sent file #{event[:payload][:path]} (#{event[:payload][:duration_ms].round(1)}ms)" }
end
- subscribe_log_level :send_file, :info
+ event_log_level :file_sent, :info
- def redirect_to(event)
- info { "Redirected to #{event.payload[:location]}" }
+ def redirected(event)
+ info { "Redirected to #{event[:payload][:location]}" }
+
+ if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location)
+ info { "↳ #{source}" }
+ end
end
- subscribe_log_level :redirect_to, :info
+ event_log_level :redirected, :info
- def send_data(event)
- info { "Sent data #{event.payload[:filename]} (#{event.duration.round(1)}ms)" }
+ def data_sent(event)
+ info { "Sent data #{event[:payload][:filename]} (#{event[:payload][:duration_ms].round(1)}ms)" }
end
- subscribe_log_level :send_data, :info
+ event_log_level :data_sent, :info
def unpermitted_parameters(event)
debug do
- unpermitted_keys = event.payload[:keys]
+ unpermitted_keys = event[:payload][:unpermitted_keys]
display_unpermitted_keys = unpermitted_keys.map { |e| ":#{e}" }.join(", ")
- context = event.payload[:context].map { |k, v| "#{k}: #{v}" }.join(", ")
+ context = event[:payload][:context].map { |k, v| "#{k}: #{v}" }.join(", ")
color("Unpermitted parameter#{'s' if unpermitted_keys.size > 1}: #{display_unpermitted_keys}. Context: { #{context} }", RED)
end
end
- subscribe_log_level :unpermitted_parameters, :debug
-
- %w(write_fragment read_fragment exist_fragment? expire_fragment).each do |method|
- class_eval <<-METHOD, __FILE__, __LINE__ + 1
- # frozen_string_literal: true
- def #{method}(event)
- return unless ActionController::Base.enable_fragment_cache_logging
- key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path])
- human_name = #{method.to_s.humanize.inspect}
- info("\#{human_name} \#{key} (\#{event.duration.round(1)}ms)")
- end
- subscribe_log_level :#{method}, :info
- METHOD
+ event_log_level :unpermitted_parameters, :debug
+
+ def fragment_cache(event)
+ return unless ActionController::Base.enable_fragment_cache_logging
+
+ key = event[:payload][:key]
+ human_name = event[:payload][:method].to_s.humanize
+
+ info("#{human_name} #{key} (#{event[:payload][:duration_ms]}ms)")
end
+ event_log_level :fragment_cache, :info
- def logger
+ def self.default_logger
ActionController::Base.logger
end
+
+ private
+ def redirect_source_location
+ backtrace_cleaner.first_clean_frame
+ end
end
end
-ActionController::LogSubscriber.attach_to :action_controller
+ActiveSupport.event_reporter.subscribe(
+ ActionController::LogSubscriber.new, &ActionController::LogSubscriber.subscription_filter
+)
diff --git a/actionpack/lib/action_controller/metal/allow_browser.rb b/actionpack/lib/action_controller/metal/allow_browser.rb
index 33afed24a60d4..f32a7cb91b08b 100644
--- a/actionpack/lib/action_controller/metal/allow_browser.rb
+++ b/actionpack/lib/action_controller/metal/allow_browser.rb
@@ -14,7 +14,7 @@ module ClassMethods
# aren't reporting a user-agent header, will be allowed access.
#
# A browser that's blocked will by default be served the file in
- # public/406-unsupported-browser.html with a HTTP status code of "406 Not
+ # public/406-unsupported-browser.html with an HTTP status code of "406 Not
# Acceptable".
#
# In addition to specifically named browser versions, you can also pass
diff --git a/actionpack/lib/action_controller/metal/conditional_get.rb b/actionpack/lib/action_controller/metal/conditional_get.rb
index 890eea8472a3b..2f172279a312c 100644
--- a/actionpack/lib/action_controller/metal/conditional_get.rb
+++ b/actionpack/lib/action_controller/metal/conditional_get.rb
@@ -332,6 +332,31 @@ def no_store
response.cache_control.replace(no_store: true)
end
+ # Adds the `must-understand` directive to the `Cache-Control` header, which indicates
+ # that a cache MUST understand the semantics of the response status code that has been
+ # received, or discard the response.
+ #
+ # This is particularly useful when returning responses with new or uncommon
+ # status codes that might not be properly interpreted by older caches.
+ #
+ # #### Example
+ #
+ # def show
+ # @article = Article.find(params[:id])
+ #
+ # if @article.early_access?
+ # must_understand
+ # render status: 203 # Non-Authoritative Information
+ # else
+ # fresh_when @article
+ # end
+ # end
+ #
+ def must_understand
+ response.cache_control[:must_understand] = true
+ response.cache_control[:no_store] = true
+ end
+
private
def combine_etags(validator, options)
[validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact
diff --git a/actionpack/lib/action_controller/metal/exceptions.rb b/actionpack/lib/action_controller/metal/exceptions.rb
index 35dd1a9138eaf..bed189eb77396 100644
--- a/actionpack/lib/action_controller/metal/exceptions.rb
+++ b/actionpack/lib/action_controller/metal/exceptions.rb
@@ -103,4 +103,9 @@ def initialize(message, controller, action_name)
super(message)
end
end
+
+ # Raised when a Rate Limit is exceeded by too many requests within a period of
+ # time.
+ class TooManyRequests < ActionControllerError
+ end
end
diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb
index 23cd5d87fe4a4..4838ed70163a5 100644
--- a/actionpack/lib/action_controller/metal/live.rb
+++ b/actionpack/lib/action_controller/metal/live.rb
@@ -133,15 +133,16 @@ def write(object, options = {})
private
def perform_write(json, options)
current_options = @options.merge(options).stringify_keys
-
+ event = +""
PERMITTED_OPTIONS.each do |option_name|
if (option_value = current_options[option_name])
- @stream.write "#{option_name}: #{option_value}\n"
+ event << "#{option_name}: #{option_value}\n"
end
end
message = json.gsub("\n", "\ndata: ")
- @stream.write "data: #{message}\n\n"
+ event << "data: #{message}\n\n"
+ @stream.write event
end
end
@@ -236,12 +237,7 @@ def call_on_error
private
def each_chunk(&block)
- loop do
- str = nil
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- str = @buf.pop
- end
- break unless str
+ while str = @buf.pop
yield str
end
end
@@ -275,16 +271,14 @@ def process(name)
# This processes the action in a child thread. It lets us return the response
# code and headers back up the Rack stack, and still process the body in
# parallel with sending data to the client.
- new_controller_thread {
+ new_controller_thread do
ActiveSupport::Dependencies.interlock.running do
t2 = Thread.current
# Since we're processing the view in a different thread, copy the thread locals
# from the main thread to the child thread. :'(
locals.each { |k, v| t2[k] = v }
- ActiveSupport::IsolatedExecutionState.share_with(t1)
-
- begin
+ ActiveSupport::IsolatedExecutionState.share_with(t1) do
super(name)
rescue => e
if @_response.committed?
@@ -301,18 +295,15 @@ def process(name)
error = e
end
ensure
- ActiveSupport::IsolatedExecutionState.clear
clean_up_thread_locals(locals, t2)
@_response.commit!
end
end
- }
-
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- @_response.await_commit
end
+ @_response.await_commit
+
raise error if error
end
diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb
index 33940664cbebe..3592827cccedd 100644
--- a/actionpack/lib/action_controller/metal/rate_limiting.rb
+++ b/actionpack/lib/action_controller/metal/rate_limiting.rb
@@ -10,16 +10,25 @@ module ClassMethods
# Applies a rate limit to all actions or those specified by the normal
# `before_action` filters with `only:` and `except:`.
#
- # The maximum number of requests allowed is specified `to:` and constrained to
+ # The maximum number of requests allowed is specified by `to:` and constrained to
# the window of time given by `within:`.
#
+ # Both `to:` and `within:` can be static values, callables,
+ # or method names (as symbols) that will be evaluated in the context of the
+ # controller processing the request.
+ #
# Rate limits are by default unique to the ip address making the request, but
# you can provide your own identity function by passing a callable in the `by:`
# parameter. It's evaluated within the context of the controller processing the
# request.
#
- # Requests that exceed the rate limit are refused with a `429 Too Many Requests`
- # response. You can specialize this by passing a callable in the `with:`
+ # By default, rate limits are scoped to the controller's path. If you want to
+ # share rate limits across multiple controllers, you can provide your own scope,
+ # by passing value in the `scope:` parameter.
+ #
+ # Requests that exceed the rate limit will raise an `ActionController::TooManyRequests`
+ # error. By default, Action Dispatch will rescue from the error and refuse the request
+ # with a `429 Too Many Requests` response. You can specialize this by passing a callable in the `with:`
# parameter. It's evaluated within the context of the controller processing the
# request.
#
@@ -40,30 +49,58 @@ module ClassMethods
#
# class SignupsController < ApplicationController
# rate_limit to: 1000, within: 10.seconds,
- # by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new
+ # by: -> { request.domain }, with: :redirect_to_busy, only: :new
+ #
+ # private
+ # def redirect_to_busy
+ # redirect_to busy_controller_url, alert: "Too many signups on domain!"
+ # end
# end
#
# class APIController < ApplicationController
# RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
# rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE
+ # rate_limit to: 100, within: 5.minutes, scope: :api_global
+ # rate_limit to: :max_requests, within: :time_window, by: -> { current_user.id }
+ #
+ # private
+ # def max_requests
+ # current_user.premium? ? 1000 : 100
+ # end
+ #
+ # def time_window
+ # current_user.premium? ? 1.hour : 1.minute
+ # end
# end
#
# class SessionsController < ApplicationController
# rate_limit to: 3, within: 2.seconds, name: "short-term"
# rate_limit to: 10, within: 5.minutes, name: "long-term"
# end
- def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: cache_store, name: nil, **options)
- before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name) }, **options
+ def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests }, store: cache_store, name: nil, scope: nil, **options)
+ before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name, scope: scope || controller_path) }, **options
end
end
private
- def rate_limiting(to:, within:, by:, with:, store:, name:)
- cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].compact.join(":")
+ def rate_limiting(to:, within:, by:, with:, store:, name:, scope:)
+ by = by.is_a?(Symbol) ? send(by) : instance_exec(&by)
+ to = to.is_a?(Symbol) ? send(to) : (to.respond_to?(:call) ? instance_exec(&to) : to)
+ within = within.is_a?(Symbol) ? send(within) : (within.respond_to?(:call) ? instance_exec(&within) : within)
+
+ cache_key = ["rate-limit", scope, name, by].compact.join(":")
count = store.increment(cache_key, 1, expires_in: within)
if count && count > to
- ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do
- instance_exec(&with)
+ ActiveSupport::Notifications.instrument("rate_limit.action_controller",
+ request: request,
+ count: count,
+ to: to,
+ within: within,
+ by: by,
+ name: name,
+ scope: scope,
+ cache_key: cache_key) do
+ with.is_a?(Symbol) ? send(with) : instance_exec(&with)
end
end
end
diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb
index 68241cdbc0ec8..6363d82b5dd80 100644
--- a/actionpack/lib/action_controller/metal/redirecting.rb
+++ b/actionpack/lib/action_controller/metal/redirecting.rb
@@ -11,10 +11,36 @@ module Redirecting
class UnsafeRedirectError < StandardError; end
+ class OpenRedirectError < UnsafeRedirectError
+ def initialize(location)
+ super("Unsafe redirect to #{location.to_s.truncate(100).inspect}, pass allow_other_host: true to redirect anyway.")
+ end
+ end
+
+ class PathRelativeRedirectError < UnsafeRedirectError
+ def initialize(url)
+ super("Path relative URL redirect detected: #{url.inspect}")
+ end
+ end
+
ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
included do
mattr_accessor :raise_on_open_redirects, default: false
+ mattr_accessor :action_on_open_redirect, default: :log
+ mattr_accessor :action_on_path_relative_redirect, default: :log
+ class_attribute :_allowed_redirect_hosts, :allowed_redirect_hosts_permissions, instance_accessor: false, instance_predicate: false
+ singleton_class.alias_method :allowed_redirect_hosts, :_allowed_redirect_hosts
+ end
+
+ module ClassMethods # :nodoc:
+ def allowed_redirect_hosts=(hosts)
+ hosts = hosts.dup.freeze
+ self._allowed_redirect_hosts = hosts
+ self.allowed_redirect_hosts_permissions = if hosts.present?
+ ActionDispatch::HostAuthorization::Permissions.new(hosts)
+ end
+ end
end
# Redirects the browser to the target specified in `options`. This parameter can
@@ -82,16 +108,17 @@ class UnsafeRedirectError < StandardError; end
# ### Open Redirect protection
#
# By default, Rails protects against redirecting to external hosts for your
- # app's safety, so called open redirects. Note: this was a new default in Rails
- # 7.0, after upgrading opt-in by uncommenting the line with
- # `raise_on_open_redirects` in
- # `config/initializers/new_framework_defaults_7_0.rb`
+ # app's safety, so called open redirects.
#
# Here #redirect_to automatically validates the potentially-unsafe URL:
#
# redirect_to params[:redirect_url]
#
- # Raises UnsafeRedirectError in the case of an unsafe redirect.
+ # The `action_on_open_redirect` configuration option controls the behavior when an unsafe
+ # redirect is detected:
+ # * `:log` - Logs a warning but allows the redirect
+ # * `:notify` - Sends an Active Support notification for monitoring
+ # * `:raise` - Raises an UnsafeRedirectError
#
# To allow any external redirects pass `allow_other_host: true`, though using a
# user-provided param in that case is unsafe.
@@ -100,11 +127,31 @@ class UnsafeRedirectError < StandardError; end
#
# See #url_from for more information on what an internal and safe URL is, or how
# to fall back to an alternate redirect URL in the unsafe case.
+ #
+ # ### Path Relative URL Redirect Protection
+ #
+ # Rails also protects against potentially unsafe path relative URL redirects that don't
+ # start with a leading slash. These can create security vulnerabilities:
+ #
+ # redirect_to "example.com" # Creates http://yourdomain.comexample.com
+ # redirect_to "@attacker.com" # Creates http://yourdomain.com@attacker.com
+ # # which browsers interpret as user@host
+ #
+ # You can configure how Rails handles these cases using:
+ #
+ # config.action_controller.action_on_path_relative_redirect = :log # default
+ # config.action_controller.action_on_path_relative_redirect = :notify
+ # config.action_controller.action_on_path_relative_redirect = :raise
+ #
+ # * `:log` - Logs a warning but allows the redirect
+ # * `:notify` - Sends an Active Support notification but allows the redirect
+ # (includes stack trace to help identify the source)
+ # * `:raise` - Raises an UnsafeRedirectError
def redirect_to(options = {}, response_options = {})
raise ActionControllerError.new("Cannot redirect to nil!") unless options
raise AbstractController::DoubleRenderError if response_body
- allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host }
+ allow_other_host = response_options.delete(:allow_other_host)
proposed_status = _extract_redirect_to_status(options, response_options)
@@ -166,6 +213,10 @@ def _compute_redirect_to_location(request, options) # :nodoc:
when /\A([a-z][a-z\d\-+.]*:|\/\/).*/i
options.to_str
when String
+ if !options.start_with?("/", "?") && !options.empty?
+ _handle_path_relative_redirect(options)
+ end
+
request.protocol + request.host_with_port + options
when Proc
_compute_redirect_to_location request, instance_eval(&options)
@@ -207,34 +258,58 @@ def url_from(location)
private
def _allow_other_host
- !raise_on_open_redirects
+ return false if raise_on_open_redirects
+
+ action_on_open_redirect != :raise
end
def _extract_redirect_to_status(options, response_options)
if options.is_a?(Hash) && options.key?(:status)
- Rack::Utils.status_code(options.delete(:status))
+ ActionDispatch::Response.rack_status_code(options.delete(:status))
elsif response_options.key?(:status)
- Rack::Utils.status_code(response_options[:status])
+ ActionDispatch::Response.rack_status_code(response_options[:status])
else
302
end
end
def _enforce_open_redirect_protection(location, allow_other_host:)
+ # Explicitly allowed other host or host is in allow list allow redirect
if allow_other_host || _url_host_allowed?(location)
location
+ # Explicitly disallowed other host
+ elsif allow_other_host == false
+ raise OpenRedirectError.new(location)
+ # Configuration disallows other hosts
+ elsif !_allow_other_host
+ raise OpenRedirectError.new(location)
+ # Log but allow redirect
+ elsif action_on_open_redirect == :log
+ logger.warn "Open redirect to #{location.inspect} detected" if logger
+ location
+ # Notify but allow redirect
+ elsif action_on_open_redirect == :notify
+ ActiveSupport::Notifications.instrument("open_redirect.action_controller",
+ location: location,
+ request: request,
+ stack_trace: caller,
+ )
+ location
+ # Fall through, should not happen but raise for safety
else
- raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway."
+ raise OpenRedirectError.new(location)
end
end
def _url_host_allowed?(url)
- host = URI(url.to_s).host
+ url_to_s = url.to_s
+ host = URI(url_to_s).host
- return true if host == request.host
- return false unless host.nil?
- return false unless url.to_s.start_with?("/")
- !url.to_s.start_with?("//")
+ if host.nil?
+ url_to_s.start_with?("/") && !url_to_s.start_with?("//")
+ else
+ host == request.host || self.class.allowed_redirect_hosts_permissions&.allows?(host)
+ end
rescue ArgumentError, URI::Error
false
end
@@ -248,5 +323,22 @@ def _ensure_url_is_http_header_safe(url)
raise UnsafeRedirectError, msg
end
end
+
+ def _handle_path_relative_redirect(url)
+ message = "Path relative URL redirect detected: #{url.inspect}"
+
+ case action_on_path_relative_redirect
+ when :log
+ logger&.warn message
+ when :notify
+ ActiveSupport::Notifications.instrument("unsafe_redirect.action_controller",
+ url: url,
+ message: message,
+ stack_trace: caller
+ )
+ when :raise
+ raise PathRelativeRedirectError.new(url)
+ end
+ end
end
end
diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb
index d3d802d8dd1c0..2065a14e089c0 100644
--- a/actionpack/lib/action_controller/metal/renderers.rb
+++ b/actionpack/lib/action_controller/metal/renderers.rb
@@ -27,8 +27,23 @@ module Renderers
# Default values are `:json`, `:js`, `:xml`.
RENDERERS = Set.new
+ module DeprecatedEscapeJsonResponses # :nodoc:
+ def escape_json_responses=(value)
+ if value
+ ActionController.deprecator.warn(<<~MSG.squish)
+ Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2.
+ Set it to `false`, or remove the config.
+ MSG
+ end
+ super
+ end
+ end
+
included do
class_attribute :_renderers, default: Set.new.freeze
+ class_attribute :escape_json_responses, instance_writer: false, instance_accessor: false, default: true
+
+ singleton_class.prepend DeprecatedEscapeJsonResponses
end
# Used in ActionController::Base and ActionController::API to include all
@@ -86,7 +101,7 @@ def self.remove(key)
remove_possible_method(method_name)
end
- def self._render_with_renderer_method_name(key)
+ def self._render_with_renderer_method_name(key) # :nodoc:
"_render_with_renderer_#{key}"
end
@@ -140,7 +155,7 @@ def render_to_body(options)
_render_to_body_with_renderer(options) || super
end
- def _render_to_body_with_renderer(options)
+ def _render_to_body_with_renderer(options) # :nodoc:
_renderers.each do |name|
if options.key?(name)
_process_options(options)
@@ -153,6 +168,7 @@ def _render_to_body_with_renderer(options)
add :json do |json, options|
json_options = options.except(:callback, :content_type, :status)
+ json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
json = json.to_json(json_options) unless json.kind_of?(String)
if options[:callback].present?
@@ -176,5 +192,10 @@ def _render_to_body_with_renderer(options)
self.content_type = :xml if media_type.nil?
xml.respond_to?(:to_xml) ? xml.to_xml(options) : xml
end
+
+ add :markdown do |md, options|
+ self.content_type = :md if media_type.nil?
+ md.respond_to?(:to_markdown) ? md.to_markdown : md
+ end
end
end
diff --git a/actionpack/lib/action_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb
index 1c8fb1f80a18a..eb2ac71d2b285 100644
--- a/actionpack/lib/action_controller/metal/rendering.rb
+++ b/actionpack/lib/action_controller/metal/rendering.rb
@@ -238,7 +238,7 @@ def _normalize_options(options)
end
if options[:status]
- options[:status] = Rack::Utils.status_code(options[:status])
+ options[:status] = ActionDispatch::Response.rack_status_code(options[:status])
end
super
diff --git a/actionpack/lib/action_controller/metal/request_forgery_protection.rb b/actionpack/lib/action_controller/metal/request_forgery_protection.rb
index 5aaed166fc544..0bd6d91261e01 100644
--- a/actionpack/lib/action_controller/metal/request_forgery_protection.rb
+++ b/actionpack/lib/action_controller/metal/request_forgery_protection.rb
@@ -642,7 +642,7 @@ def valid_request_origin? # :doc:
end
end
- def normalize_action_path(action_path) # :doc:
+ def normalize_action_path(action_path)
uri = URI.parse(action_path)
if uri.relative? && (action_path.blank? || !action_path.start_with?("/"))
@@ -652,7 +652,7 @@ def normalize_action_path(action_path) # :doc:
end
end
- def normalize_relative_action_path(rel_action_path) # :doc:
+ def normalize_relative_action_path(rel_action_path)
uri = URI.parse(request.path)
# add the action path to the request.path
uri.path += "/#{rel_action_path}"
diff --git a/actionpack/lib/action_controller/metal/rescue.rb b/actionpack/lib/action_controller/metal/rescue.rb
index 634dd5dcd0a26..4affddde7e8dd 100644
--- a/actionpack/lib/action_controller/metal/rescue.rb
+++ b/actionpack/lib/action_controller/metal/rescue.rb
@@ -13,6 +13,15 @@ module Rescue
extend ActiveSupport::Concern
include ActiveSupport::Rescuable
+ module ClassMethods
+ def handler_for_rescue(exception, ...) # :nodoc:
+ if handler = super
+ ActiveSupport::Notifications.instrument("rescue_from_callback.action_controller", exception: exception)
+ handler
+ end
+ end
+ end
+
# Override this method if you want to customize when detailed exceptions must be
# shown. This method is only called when `consider_all_requests_local` is
# `false`. By default, it returns `false`, but someone may set it to
diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb
index e81f82a90d4ac..3180e8e3b38df 100644
--- a/actionpack/lib/action_controller/metal/strong_parameters.rb
+++ b/actionpack/lib/action_controller/metal/strong_parameters.rb
@@ -316,6 +316,10 @@ def hash
[self.class, @parameters, @permitted].hash
end
+ def deconstruct_keys(keys)
+ slice(*keys).each.with_object({}) { |(key, value), hash| hash.merge!(key.to_sym => value) }
+ end
+
# Returns a safe ActiveSupport::HashWithIndifferentAccess representation of the
# parameters with all unpermitted keys removed.
#
diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb
index 4b0619402e5ad..fc4f3cbfbdba2 100644
--- a/actionpack/lib/action_controller/railtie.rb
+++ b/actionpack/lib/action_controller/railtie.rb
@@ -12,9 +12,11 @@
module ActionController
class Railtie < Rails::Railtie # :nodoc:
config.action_controller = ActiveSupport::OrderedOptions.new
- config.action_controller.raise_on_open_redirects = false
+ config.action_controller.action_on_open_redirect = :log
+ config.action_controller.action_on_path_relative_redirect = :log
config.action_controller.log_query_tags_around_actions = true
config.action_controller.wrap_parameters_by_default = false
+ config.action_controller.allowed_redirect_hosts = []
config.eager_load_namespaces << AbstractController
config.eager_load_namespaces << ActionController
@@ -55,7 +57,8 @@ class Railtie < Rails::Railtie # :nodoc:
paths = app.config.paths
options = app.config.action_controller
- options.logger ||= Rails.logger
+ options.logger = options.fetch(:logger, Rails.logger)
+
options.cache_store ||= Rails.cache
options.javascripts_dir ||= paths["public/javascripts"].first
@@ -101,6 +104,22 @@ class Railtie < Rails::Railtie # :nodoc:
end
end
+ initializer "action_controller.open_redirects" do |app|
+ ActiveSupport.on_load(:action_controller, run_once: true) do
+ if app.config.action_controller.has_key?(:raise_on_open_redirects)
+ ActiveSupport.deprecator.warn(<<~MSG.squish)
+ `raise_on_open_redirects` is deprecated and will be removed in a future Rails version.
+ Use `config.action_controller.action_on_open_redirect = :raise` instead.
+ MSG
+
+ # Fallback to the default behavior in case of `load_default` set `action_on_open_redirect`, but apps set `raise_on_open_redirects`.
+ if app.config.action_controller.raise_on_open_redirects == false && app.config.action_controller.action_on_open_redirect == :raise
+ self.action_on_open_redirect = :log
+ end
+ end
+ end
+ end
+
initializer "action_controller.query_log_tags" do |app|
query_logs_tags_enabled = app.config.respond_to?(:active_record) &&
app.config.active_record.query_log_tags_enabled &&
@@ -114,15 +133,7 @@ class Railtie < Rails::Railtie # :nodoc:
ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.merge(
controller: ->(context) { context[:controller]&.controller_name },
action: ->(context) { context[:controller]&.action_name },
- namespaced_controller: ->(context) {
- if context[:controller]
- controller_class = context[:controller].class
- # based on ActionController::Metal#controller_name, but does not demodulize
- unless controller_class.anonymous?
- controller_class.name.delete_suffix("Controller").underscore
- end
- end
- }
+ namespaced_controller: ->(context) { context[:controller]&.controller_path }
)
end
end
@@ -133,5 +144,11 @@ class Railtie < Rails::Railtie # :nodoc:
ActionController::TestCase.executor_around_each_request = app.config.active_support.executor_around_test_case
end
end
+
+ initializer "action_controller.backtrace_cleaner" do
+ ActiveSupport.on_load(:action_controller) do
+ ActionController::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner
+ end
+ end
end
end
diff --git a/actionpack/lib/action_controller/renderer.rb b/actionpack/lib/action_controller/renderer.rb
index 068d023bc9585..dd0f01c345b56 100644
--- a/actionpack/lib/action_controller/renderer.rb
+++ b/actionpack/lib/action_controller/renderer.rb
@@ -96,7 +96,6 @@ def with_defaults(defaults)
# * `:script_name` - The portion of the incoming request's URL path that
# corresponds to the application. Converts to Rack's `SCRIPT_NAME`.
# * `:input` - The input stream. Converts to Rack's `rack.input`.
- #
# * `defaults` - Default values for the Rack env. Entries are specified in the
# same format as `env`. `env` will be merged on top of these values.
# `defaults` will be retained when calling #new on a renderer instance.
diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb
new file mode 100644
index 0000000000000..8bee0acffeb2f
--- /dev/null
+++ b/actionpack/lib/action_controller/structured_event_subscriber.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module ActionController
+ class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc:
+ INTERNAL_PARAMS = %w(controller action format _method only_path)
+
+ def start_processing(event)
+ payload = event.payload
+ params = {}
+ payload[:params].each_pair do |k, v|
+ params[k] = v unless INTERNAL_PARAMS.include?(k)
+ end
+ format = payload[:format]
+ format = format.to_s.upcase if format.is_a?(Symbol)
+ format = "*/*" if format.nil?
+
+ emit_event("action_controller.request_started",
+ controller: payload[:controller],
+ action: payload[:action],
+ format:,
+ params:,
+ )
+ end
+
+ def process_action(event)
+ payload = event.payload
+ status = payload[:status]
+
+ if status.nil? && (exception_class_name = payload[:exception]&.first)
+ status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name)
+ end
+
+ emit_event("action_controller.request_completed", {
+ controller: payload[:controller],
+ action: payload[:action],
+ status: status,
+ **additions_for(payload),
+ duration_ms: event.duration.round(2),
+ gc_time_ms: event.gc_time.round(1),
+ }.compact)
+ end
+
+ def halted_callback(event)
+ emit_event("action_controller.callback_halted", filter: event.payload[:filter])
+ end
+
+ def rescue_from_callback(event)
+ exception = event.payload[:exception]
+
+ exception_backtrace = exception.backtrace&.first
+ exception_backtrace = exception_backtrace&.delete_prefix("#{Rails.root}/") if defined?(Rails.root) && Rails.root
+
+ emit_event("action_controller.rescue_from_handled",
+ exception_class: exception.class.name,
+ exception_message: exception.message,
+ exception_backtrace:
+ )
+ end
+
+ def send_file(event)
+ emit_event("action_controller.file_sent", path: event.payload[:path], duration_ms: event.duration.round(1))
+ end
+
+ def redirect_to(event)
+ emit_event("action_controller.redirected", location: event.payload[:location])
+ end
+
+ def send_data(event)
+ emit_event("action_controller.data_sent", filename: event.payload[:filename], duration_ms: event.duration.round(1))
+ end
+
+ def unpermitted_parameters(event)
+ unpermitted_keys = event.payload[:keys]
+ context = event.payload[:context]
+
+ emit_debug_event("action_controller.unpermitted_parameters",
+ unpermitted_keys:,
+ context: context.except(:request)
+ )
+ end
+ debug_only :unpermitted_parameters
+
+ def write_fragment(event)
+ fragment_cache(__method__, event)
+ end
+
+ def read_fragment(event)
+ fragment_cache(__method__, event)
+ end
+
+ def exist_fragment?(event)
+ fragment_cache(__method__, event)
+ end
+
+ def expire_fragment(event)
+ fragment_cache(__method__, event)
+ end
+
+ private
+ def fragment_cache(method_name, event)
+ key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path])
+
+ emit_event("action_controller.fragment_cache",
+ method: "#{method_name}",
+ key: key,
+ duration_ms: event.duration.round(1)
+ )
+ end
+
+ def additions_for(payload)
+ payload.slice(:view_runtime, :db_runtime, :queries_count, :cached_queries_count)
+ end
+ end
+end
+
+ActionController::StructuredEventSubscriber.attach_to :action_controller
diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb
index 24107c901b15c..c24348d13e7e5 100644
--- a/actionpack/lib/action_dispatch.rb
+++ b/actionpack/lib/action_dispatch.rb
@@ -138,6 +138,14 @@ def self.resolve_store(session_store) # :nodoc:
autoload :SystemTestCase, "action_dispatch/system_test_case"
+ ##
+ # :singleton-method:
+ #
+ # Specifies if the methods calling redirects in controllers and routes should
+ # be logged below their relevant log lines. Defaults to false.
+ singleton_class.attr_accessor :verbose_redirect_logs
+ self.verbose_redirect_logs = false
+
def eager_load!
super
Routing.eager_load!
diff --git a/actionpack/lib/action_dispatch/constants.rb b/actionpack/lib/action_dispatch/constants.rb
index c1b53150e1ebc..325040400305e 100644
--- a/actionpack/lib/action_dispatch/constants.rb
+++ b/actionpack/lib/action_dispatch/constants.rb
@@ -30,5 +30,11 @@ module Constants
SERVER_TIMING = "server-timing"
STRICT_TRANSPORT_SECURITY = "strict-transport-security"
end
+
+ if Gem::Version.new(Rack::RELEASE) < Gem::Version.new("3.1")
+ UNPROCESSABLE_CONTENT = :unprocessable_entity
+ else
+ UNPROCESSABLE_CONTENT = :unprocessable_content
+ end
end
end
diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb
index 8f9bfc8528878..1486309d85edd 100644
--- a/actionpack/lib/action_dispatch/http/cache.rb
+++ b/actionpack/lib/action_dispatch/http/cache.rb
@@ -63,6 +63,114 @@ def fresh?(response)
success
end
end
+
+ def cache_control_directives
+ @cache_control_directives ||= CacheControlDirectives.new(get_header("HTTP_CACHE_CONTROL"))
+ end
+
+ # Represents the HTTP Cache-Control header for requests,
+ # providing methods to access various cache control directives
+ # Reference: https://www.rfc-editor.org/rfc/rfc9111.html#name-request-directives
+ class CacheControlDirectives
+ def initialize(cache_control_header)
+ @only_if_cached = false
+ @no_cache = false
+ @no_store = false
+ @no_transform = false
+ @max_age = nil
+ @max_stale = nil
+ @min_fresh = nil
+ @stale_if_error = false
+ parse_directives(cache_control_header)
+ end
+
+ # Returns true if the only-if-cached directive is present.
+ # This directive indicates that the client only wishes to obtain a
+ # stored response. If a valid stored response is not available,
+ # the server should respond with a 504 (Gateway Timeout) status.
+ def only_if_cached?
+ @only_if_cached
+ end
+
+ # Returns true if the no-cache directive is present.
+ # This directive indicates that a cache must not use the response
+ # to satisfy subsequent requests without successful validation on the origin server.
+ def no_cache?
+ @no_cache
+ end
+
+ # Returns true if the no-store directive is present.
+ # This directive indicates that a cache must not store any part of the
+ # request or response.
+ def no_store?
+ @no_store
+ end
+
+ # Returns true if the no-transform directive is present.
+ # This directive indicates that a cache or proxy must not transform the payload.
+ def no_transform?
+ @no_transform
+ end
+
+ # Returns the value of the max-age directive.
+ # This directive indicates that the client is willing to accept a response
+ # whose age is no greater than the specified number of seconds.
+ attr_reader :max_age
+
+ # Returns the value of the max-stale directive.
+ # When max-stale is present with a value, returns that integer value.
+ # When max-stale is present without a value, returns true (unlimited staleness).
+ # When max-stale is not present, returns nil.
+ attr_reader :max_stale
+
+ # Returns true if max-stale directive is present (with or without a value)
+ def max_stale?
+ !@max_stale.nil?
+ end
+
+ # Returns true if max-stale directive is present without a value (unlimited staleness)
+ def max_stale_unlimited?
+ @max_stale == true
+ end
+
+ # Returns the value of the min-fresh directive.
+ # This directive indicates that the client is willing to accept a response
+ # whose freshness lifetime is no less than its current age plus the specified time in seconds.
+ attr_reader :min_fresh
+
+ # Returns the value of the stale-if-error directive.
+ # This directive indicates that the client is willing to accept a stale response
+ # if the check for a fresh one fails with an error for the specified number of seconds.
+ attr_reader :stale_if_error
+
+ private
+ def parse_directives(header_value)
+ return unless header_value
+
+ header_value.delete(" ").downcase.split(",").each do |directive|
+ name, value = directive.split("=", 2)
+
+ case name
+ when "max-age"
+ @max_age = value.to_i
+ when "min-fresh"
+ @min_fresh = value.to_i
+ when "stale-if-error"
+ @stale_if_error = value.to_i
+ when "no-cache"
+ @no_cache = true
+ when "no-store"
+ @no_store = true
+ when "no-transform"
+ @no_transform = true
+ when "only-if-cached"
+ @only_if_cached = true
+ when "max-stale"
+ @max_stale = value ? value.to_i : true
+ end
+ end
+ end
+ end
end
module Response
@@ -142,7 +250,7 @@ def strong_etag?
private
DATE = "Date"
LAST_MODIFIED = "Last-Modified"
- SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate])
+ SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate must-understand])
def generate_weak_etag(validators)
"W/#{generate_strong_etag(validators)}"
@@ -187,6 +295,7 @@ def prepare_cache_control!
PRIVATE = "private"
MUST_REVALIDATE = "must-revalidate"
IMMUTABLE = "immutable"
+ MUST_UNDERSTAND = "must-understand"
def handle_conditional_get!
# Normally default cache control setting is handled by ETag middleware. But, if
@@ -221,6 +330,7 @@ def merge_and_normalize_cache_control!(cache_control)
if control[:no_store]
options << PRIVATE if control[:private]
+ options << MUST_UNDERSTAND if control[:must_understand]
options << NO_STORE
elsif control[:no_cache]
options << PUBLIC if control[:public]
diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb
index 9194765a2e27e..2c21ff9a4120f 100644
--- a/actionpack/lib/action_dispatch/http/content_security_policy.rb
+++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb
@@ -171,6 +171,8 @@ def generate_content_security_policy_nonce
worker_src: "worker-src"
}.freeze
+ HASH_SOURCE_ALGORITHM_PREFIXES = ["sha256-", "sha384-", "sha512-"].freeze
+
DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze
private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES
@@ -305,7 +307,13 @@ def apply_mappings(sources)
case source
when Symbol
apply_mapping(source)
- when String, Proc
+ when String
+ if hash_source?(source)
+ "'#{source}'"
+ else
+ source
+ end
+ when Proc
source
else
raise ArgumentError, "Invalid content security policy source: #{source.inspect}"
@@ -374,5 +382,9 @@ def resolve_source(source, context)
def nonce_directive?(directive, nonce_directives)
nonce_directives.include?(directive)
end
+
+ def hash_source?(source)
+ source.start_with?(*HASH_SOURCE_ALGORITHM_PREFIXES)
+ end
end
end
diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb
index ce44efb965353..2947c719f17cd 100644
--- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb
+++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb
@@ -56,9 +56,14 @@ def accepts
# Returns the MIME type for the format used in the request.
#
- # GET /posts/5.xml | request.format => Mime[:xml]
- # GET /posts/5.xhtml | request.format => Mime[:html]
- # GET /posts/5 | request.format => Mime[:html] or Mime[:js], or request.accepts.first
+ # # GET /posts/5.xml
+ # request.format # => Mime[:xml]
+ #
+ # # GET /posts/5.xhtml
+ # request.format # => Mime[:html]
+ #
+ # # GET /posts/5
+ # request.format # => Mime[:html] or Mime[:js], or request.accepts.first
#
def format(_view_path = nil)
formats.first || Mime::NullType.instance
@@ -86,7 +91,49 @@ def formats
end
end
- # Sets the variant for template.
+ # Sets the \variant for the response template.
+ #
+ # When determining which template to render, Action View will incorporate
+ # all variants from the request. For example, if an
+ # `ArticlesController#index` action needs to respond to
+ # `request.variant = [:ios, :turbo_native]`, it will render the
+ # first template file it can find in the following list:
+ #
+ # - `app/views/articles/index.html+ios.erb`
+ # - `app/views/articles/index.html+turbo_native.erb`
+ # - `app/views/articles/index.html.erb`
+ #
+ # Variants add context to the requests that views render appropriately.
+ # Variant names are arbitrary, and can communicate anything from the
+ # request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`)
+ # to its browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type
+ # of user (`:admin`, `:guest`, `:user`).
+ #
+ # Note: Adding many new variant templates with similarities to existing
+ # template files can make maintaining your view code more difficult.
+ #
+ # #### Parameters
+ #
+ # * `variant` - a symbol name or an array of symbol names for variants
+ # used to render the response template
+ #
+ # #### Examples
+ #
+ # class ApplicationController < ActionController::Base
+ # before_action :determine_variants
+ #
+ # private
+ # def determine_variants
+ # variants = []
+ #
+ # # some code to determine the variant(s) to use
+ #
+ # variants << :ios if request.user_agent.include?("iOS")
+ # variants << :turbo_native if request.user_agent.include?("Turbo Native")
+ #
+ # request.variant = variants
+ # end
+ # end
def variant=(variant)
variant = Array(variant)
@@ -97,6 +144,18 @@ def variant=(variant)
end
end
+ # Returns the \variant for the response template as an instance of
+ # ActiveSupport::ArrayInquirer.
+ #
+ # request.variant = :phone
+ # request.variant.phone? # => true
+ # request.variant.tablet? # => false
+ #
+ # request.variant = [:phone, :tablet]
+ # request.variant.phone? # => true
+ # request.variant.desktop? # => false
+ # request.variant.any?(:phone, :desktop) # => true
+ # request.variant.any?(:desktop, :watch) # => false
def variant
@variant ||= ActiveSupport::ArrayInquirer.new
end
diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb
index 426154fe57dea..56e00af88d4ec 100644
--- a/actionpack/lib/action_dispatch/http/mime_types.rb
+++ b/actionpack/lib/action_dispatch/http/mime_types.rb
@@ -13,6 +13,7 @@
Mime::Type.register "text/csv", :csv
Mime::Type.register "text/vcard", :vcf
Mime::Type.register "text/vtt", :vtt, %w(vtt)
+Mime::Type.register "text/markdown", :md, [], %w(md markdown)
Mime::Type.register "image/png", :png, [], %w(png)
Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg)
diff --git a/actionpack/lib/action_dispatch/http/param_builder.rb b/actionpack/lib/action_dispatch/http/param_builder.rb
index cf6cd6f9da16e..6e8a9e1281293 100644
--- a/actionpack/lib/action_dispatch/http/param_builder.rb
+++ b/actionpack/lib/action_dispatch/http/param_builder.rb
@@ -16,15 +16,27 @@ def initialize(param_depth_limit)
@param_depth_limit = param_depth_limit
end
- cattr_accessor :ignore_leading_brackets
-
- LEADING_BRACKETS_COMPAT = defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s.start_with?("2.")
-
cattr_accessor :default
self.default = make_default(100)
class << self
delegate :from_query_string, :from_pairs, :from_hash, to: :default
+
+ def ignore_leading_brackets
+ ActionDispatch.deprecator.warn <<~MSG
+ ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2.
+ MSG
+
+ @ignore_leading_brackets
+ end
+
+ def ignore_leading_brackets=(value)
+ ActionDispatch.deprecator.warn <<~MSG
+ ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2.
+ MSG
+
+ @ignore_leading_brackets = value
+ end
end
def from_query_string(qs, separator: nil, encoding_template: nil)
@@ -69,30 +81,15 @@ def store_nested_param(params, name, v, depth, encoding_template = nil)
# nil name, treat same as empty string (required by tests)
k = after = ""
elsif depth == 0
- if ignore_leading_brackets || (ignore_leading_brackets.nil? && LEADING_BRACKETS_COMPAT)
- # Rack 2 compatible behavior, ignore leading brackets
- if name =~ /\A[\[\]]*([^\[\]]+)\]*/
- k = $1
- after = $' || ""
-
- if !ignore_leading_brackets && (k != $& || !after.empty? && !after.start_with?("["))
- ActionDispatch.deprecator.warn("Skipping over leading brackets in parameter name #{name.inspect} is deprecated and will parse differently in Rails 8.1 or Rack 3.0.")
- end
- else
- k = name
- after = ""
- end
+ # Start of parsing, don't treat [] or [ at start of string specially
+ if start = name.index("[", 1)
+ # Start of parameter nesting, use part before brackets as key
+ k = name[0, start]
+ after = name[start, name.length]
else
- # Start of parsing, don't treat [] or [ at start of string specially
- if start = name.index("[", 1)
- # Start of parameter nesting, use part before brackets as key
- k = name[0, start]
- after = name[start, name.length]
- else
- # Plain parameter with no nesting
- k = name
- after = ""
- end
+ # Plain parameter with no nesting
+ k = name
+ after = ""
end
elsif name.start_with?("[]")
# Array nesting
@@ -111,6 +108,10 @@ def store_nested_param(params, name, v, depth, encoding_template = nil)
return if k.empty?
+ unless k.valid_encoding?
+ raise InvalidParameterError, "Invalid encoding for parameter: #{k}"
+ end
+
if depth == 0 && String === v
# We have to wait until we've found the top part of the name,
# because that's what the encoding template is configured with
diff --git a/actionpack/lib/action_dispatch/http/query_parser.rb b/actionpack/lib/action_dispatch/http/query_parser.rb
index 55488b6170858..6afdb64434fc0 100644
--- a/actionpack/lib/action_dispatch/http/query_parser.rb
+++ b/actionpack/lib/action_dispatch/http/query_parser.rb
@@ -6,12 +6,21 @@
module ActionDispatch
class QueryParser
DEFAULT_SEP = /& */n
- COMPAT_SEP = /[&;] */n
COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n, "&;" => /[&;] */n }
- cattr_accessor :strict_query_string_separator
+ def self.strict_query_string_separator
+ ActionDispatch.deprecator.warn <<~MSG
+ The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2.
+ MSG
+ @strict_query_string_separator
+ end
- SEMICOLON_COMPAT = defined?(::Rack::QueryParser::DEFAULT_SEP) && ::Rack::QueryParser::DEFAULT_SEP.to_s.include?(";")
+ def self.strict_query_string_separator=(value)
+ ActionDispatch.deprecator.warn <<~MSG
+ The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2.
+ MSG
+ @strict_query_string_separator = value
+ end
#--
# Note this departs from WHATWG's specified parsing algorithm by
@@ -25,13 +34,6 @@ def self.each_pair(s, separator = nil)
splitter =
if separator
COMMON_SEP[separator] || /[#{separator}] */n
- elsif strict_query_string_separator
- DEFAULT_SEP
- elsif SEMICOLON_COMPAT && s.include?(";")
- if strict_query_string_separator.nil?
- ActionDispatch.deprecator.warn("Using semicolon as a query string separator is deprecated and will not be supported in Rails 8.1 or Rack 3.0. Use `&` instead.")
- end
- COMPAT_SEP
else
DEFAULT_SEP
end
diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb
index e3792aea8f7da..a29b99cf8eb88 100644
--- a/actionpack/lib/action_dispatch/http/response.rb
+++ b/actionpack/lib/action_dispatch/http/response.rb
@@ -46,6 +46,20 @@ class Response
Headers = ::Rack::Utils::HeaderHash
end
+ class << self
+ if ActionDispatch::Constants::UNPROCESSABLE_CONTENT == :unprocessable_content
+ def rack_status_code(status) # :nodoc:
+ status = :unprocessable_content if status == :unprocessable_entity
+ Rack::Utils.status_code(status)
+ end
+ else
+ def rack_status_code(status) # :nodoc:
+ status = :unprocessable_entity if status == :unprocessable_content
+ Rack::Utils.status_code(status)
+ end
+ end
+ end
+
# To be deprecated:
Header = Headers
@@ -257,7 +271,7 @@ def sent?; synchronize { @sent }; end
# Sets the HTTP status code.
def status=(status)
- @status = Rack::Utils.status_code(status)
+ @status = Response.rack_status_code(status)
end
# Sets the HTTP response's content MIME type. For example, in the controller you
diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb
index 669c34f9fc9e1..e7fecc7b756a7 100644
--- a/actionpack/lib/action_dispatch/http/url.rb
+++ b/actionpack/lib/action_dispatch/http/url.rb
@@ -11,8 +11,105 @@ module URL
HOST_REGEXP = /(^[^:]+:\/\/)?(\[[^\]]+\]|[^:]+)(?::(\d+$))?/
PROTOCOL_REGEXP = /^([^:]+)(:)?(\/\/)?$/
+ # DomainExtractor provides utility methods for extracting domain and subdomain
+ # information from host strings. This module is used internally by Action Dispatch
+ # to parse host names and separate the domain from subdomains based on the
+ # top-level domain (TLD) length.
+ #
+ # The module assumes a standard domain structure where domains consist of:
+ # - Subdomains (optional, can be multiple levels)
+ # - Domain name
+ # - Top-level domain (TLD, can be multiple levels like .co.uk)
+ #
+ # For example, in "api.staging.example.co.uk":
+ # - Subdomains: ["api", "staging"]
+ # - Domain: "example.co.uk" (with tld_length=2)
+ # - TLD: "co.uk"
+ module DomainExtractor
+ extend self
+
+ # Extracts the domain part from a host string, including the specified
+ # number of top-level domain components.
+ #
+ # The domain includes the main domain name plus the TLD components.
+ # The +tld_length+ parameter specifies how many components from the right
+ # should be considered part of the TLD.
+ #
+ # ==== Parameters
+ #
+ # [+host+]
+ # The host string to extract the domain from.
+ #
+ # [+tld_length+]
+ # The number of domain components that make up the TLD. For example,
+ # use 1 for ".com" or 2 for ".co.uk".
+ #
+ # ==== Examples
+ #
+ # # Standard TLD (tld_length = 1)
+ # DomainExtractor.domain_from("www.example.com", 1)
+ # # => "example.com"
+ #
+ # # Country-code TLD (tld_length = 2)
+ # DomainExtractor.domain_from("www.example.co.uk", 2)
+ # # => "example.co.uk"
+ #
+ # # Multiple subdomains
+ # DomainExtractor.domain_from("api.staging.myapp.herokuapp.com", 1)
+ # # => "herokuapp.com"
+ #
+ # # Single component (returns the host itself)
+ # DomainExtractor.domain_from("localhost", 1)
+ # # => "localhost"
+ def domain_from(host, tld_length)
+ host.split(".").last(1 + tld_length).join(".")
+ end
+
+ # Extracts the subdomain components from a host string as an Array.
+ #
+ # Returns all the components that come before the domain and TLD parts.
+ # The +tld_length+ parameter is used to determine where the domain begins
+ # so that everything before it is considered a subdomain.
+ #
+ # ==== Parameters
+ #
+ # [+host+]
+ # The host string to extract subdomains from.
+ #
+ # [+tld_length+]
+ # The number of domain components that make up the TLD. This affects
+ # where the domain boundary is calculated.
+ #
+ # ==== Examples
+ #
+ # # Standard TLD (tld_length = 1)
+ # DomainExtractor.subdomains_from("www.example.com", 1)
+ # # => ["www"]
+ #
+ # # Country-code TLD (tld_length = 2)
+ # DomainExtractor.subdomains_from("api.staging.example.co.uk", 2)
+ # # => ["api", "staging"]
+ #
+ # # No subdomains
+ # DomainExtractor.subdomains_from("example.com", 1)
+ # # => []
+ #
+ # # Single subdomain with complex TLD
+ # DomainExtractor.subdomains_from("www.mysite.co.uk", 2)
+ # # => ["www"]
+ #
+ # # Multiple levels of subdomains
+ # DomainExtractor.subdomains_from("dev.api.staging.example.com", 1)
+ # # => ["dev", "api", "staging"]
+ def subdomains_from(host, tld_length)
+ parts = host.split(".")
+ parts[0..-(tld_length + 2)]
+ end
+ end
+
mattr_accessor :secure_protocol, default: false
mattr_accessor :tld_length, default: 1
+ mattr_accessor :domain_extractor, default: DomainExtractor
class << self
# Returns the domain part of a host given the domain level.
@@ -96,34 +193,33 @@ def add_anchor(path, anchor)
end
def extract_domain_from(host, tld_length)
- host.split(".").last(1 + tld_length).join(".")
+ domain_extractor.domain_from(host, tld_length)
end
def extract_subdomains_from(host, tld_length)
- parts = host.split(".")
- parts[0..-(tld_length + 2)]
+ domain_extractor.subdomains_from(host, tld_length)
end
def build_host_url(host, port, protocol, options, path)
if match = host.match(HOST_REGEXP)
- protocol ||= match[1] unless protocol == false
- host = match[2]
- port = match[3] unless options.key? :port
+ protocol_from_host = match[1] if protocol.nil?
+ host = match[2]
+ port = match[3] unless options.key? :port
end
- protocol = normalize_protocol protocol
+ protocol = protocol_from_host || normalize_protocol(protocol).dup
host = normalize_host(host, options)
+ port = normalize_port(port, protocol)
- result = protocol.dup
+ result = protocol
if options[:user] && options[:password]
result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@"
end
result << host
- normalize_port(port, protocol) { |normalized_port|
- result << ":#{normalized_port}"
- }
+
+ result << ":" << port.to_s if port
result.concat path
end
@@ -169,11 +265,11 @@ def normalize_port(port, protocol)
return unless port
case protocol
- when "//" then yield port
+ when "//" then port
when "https://"
- yield port unless port.to_i == 443
+ port unless port.to_i == 443
else
- yield port unless port.to_i == 80
+ port unless port.to_i == 80
end
end
end
@@ -272,7 +368,7 @@ def standard_port
end
end
- # Returns whether this request is using the standard port
+ # Returns whether this request is using the standard port.
#
# req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80'
# req.standard_port? # => true
@@ -307,7 +403,7 @@ def port_string
standard_port? ? "" : ":#{port}"
end
- # Returns the requested port, such as 8080, based on SERVER_PORT
+ # Returns the requested port, such as 8080, based on SERVER_PORT.
#
# req = ActionDispatch::Request.new 'SERVER_PORT' => '80'
# req.server_port # => 80
diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb
index ca3d05599e1e4..239135ec8af0d 100644
--- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb
+++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb
@@ -13,7 +13,6 @@ class TransitionTable # :nodoc:
attr_reader :memos
DEFAULT_EXP = /[^.\/?]+/
- DEFAULT_EXP_ANCHORED = /\A#{DEFAULT_EXP}\Z/
def initialize
@stdparam_states = {}
@@ -111,10 +110,10 @@ def as_json(options = nil)
end
{
- regexp_states: simple_regexp,
- string_states: @string_states,
- stdparam_states: @stdparam_states,
- accepting: @accepting
+ regexp_states: simple_regexp.stringify_keys,
+ string_states: @string_states.stringify_keys,
+ stdparam_states: @stdparam_states.stringify_keys,
+ accepting: @accepting.stringify_keys
}
end
@@ -193,12 +192,15 @@ def states
end
def transitions
+ # double escaped because dot evaluates escapes
+ default_exp_anchored = "\\\\A#{DEFAULT_EXP.source}\\\\Z"
+
@string_states.flat_map { |from, hash|
hash.map { |s, to| [from, s, to] }
} + @stdparam_states.map { |from, to|
- [from, DEFAULT_EXP_ANCHORED, to]
+ [from, default_exp_anchored, to]
} + @regexp_states.flat_map { |from, hash|
- hash.map { |s, to| [from, s, to] }
+ hash.map { |r, to| [from, r.source.gsub("\\") { "\\\\" }, to] }
}
end
end
diff --git a/actionpack/lib/action_dispatch/journey/path/pattern.rb b/actionpack/lib/action_dispatch/journey/path/pattern.rb
index 291a9d21b0145..c0f24d3145bec 100644
--- a/actionpack/lib/action_dispatch/journey/path/pattern.rb
+++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb
@@ -6,6 +6,14 @@ module ActionDispatch
module Journey # :nodoc:
module Path # :nodoc:
class Pattern # :nodoc:
+ REGEXP_CACHE = {}
+
+ class << self
+ def dedup_regexp(regexp)
+ REGEXP_CACHE[regexp.source] ||= regexp
+ end
+ end
+
attr_reader :ast, :names, :requirements, :anchored, :spec
def initialize(ast, requirements, separators, anchored)
@@ -74,7 +82,7 @@ def initialize(separator, matchers)
end
def accept(node)
- %r{\A#{visit node}\Z}
+ Pattern.dedup_regexp(%r{\A#{visit node}\Z})
end
def visit_CAT(node)
@@ -117,7 +125,7 @@ def visit_OR(node)
class UnanchoredRegexp < AnchoredRegexp # :nodoc:
def accept(node)
path = visit node
- path == "/" ? %r{\A/} : %r{\A#{path}(?:\b|\Z|/)}
+ path == "/" ? %r{\A/} : Pattern.dedup_regexp(%r{\A#{path}(?:\b|\Z|/)})
end
end
@@ -176,7 +184,7 @@ def to_regexp
def requirements_for_missing_keys_check
@requirements_for_missing_keys_check ||= requirements.transform_values do |regex|
- /\A#{regex}\Z/
+ Pattern.dedup_regexp(/\A#{regex}\Z/)
end
end
@@ -193,7 +201,7 @@ def offsets
node = node.to_sym
if @requirements.key?(node)
- re = /#{Regexp.union(@requirements[node])}|/
+ re = Pattern.dedup_regexp(/#{Regexp.union(@requirements[node])}|/)
offsets.push((re.match("").length - 1) + offsets.last)
else
offsets << offsets.last
diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb
index 7f27131030980..fc8ffd0f3a1ae 100644
--- a/actionpack/lib/action_dispatch/journey/router.rb
+++ b/actionpack/lib/action_dispatch/journey/router.rb
@@ -2,7 +2,8 @@
# :markup: markdown
-require "cgi"
+require "cgi/escape"
+require "cgi/util" if RUBY_VERSION < "3.5"
require "action_dispatch/journey/router/utils"
require "action_dispatch/journey/routes"
require "action_dispatch/journey/formatter"
diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb
index c9d5d4ba7fb06..94161c9563214 100644
--- a/actionpack/lib/action_dispatch/log_subscriber.rb
+++ b/actionpack/lib/action_dispatch/log_subscriber.rb
@@ -1,25 +1,38 @@
# frozen_string_literal: true
-# :markup: markdown
-
module ActionDispatch
- class LogSubscriber < ActiveSupport::LogSubscriber
+ class LogSubscriber < ActiveSupport::EventReporter::LogSubscriber # :nodoc:
+ class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new
+
+ self.namespace = "action_dispatch"
+
def redirect(event)
- payload = event.payload
+ payload = event[:payload]
info { "Redirected to #{payload[:location]}" }
+ if ActionDispatch.verbose_redirect_logs
+ info { "↳ #{payload[:source_location]}" }
+ end
+
info do
status = payload[:status]
+ status_name = payload[:status_name]
- message = +"Completed #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]} in #{event.duration.round}ms"
+ message = +"Completed #{status} #{status_name} in #{payload[:duration_ms].round}ms"
message << "\n\n" if defined?(Rails.env) && Rails.env.development?
message
end
end
- subscribe_log_level :redirect, :info
+ event_log_level :redirect, :info
+
+ def self.default_logger
+ ActionController::Base.logger
+ end
end
end
-ActionDispatch::LogSubscriber.attach_to :action_dispatch
+ActiveSupport.event_reporter.subscribe(
+ ActionDispatch::LogSubscriber.new, &ActionDispatch::LogSubscriber.subscription_filter
+)
diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb
index dbf07d52545f0..351daf9224167 100644
--- a/actionpack/lib/action_dispatch/middleware/cookies.rb
+++ b/actionpack/lib/action_dispatch/middleware/cookies.rb
@@ -610,8 +610,10 @@ def commit(name, options)
end
def check_for_overflow!(name, options)
- if options[:value].bytesize > MAX_COOKIE_SIZE
- raise CookieOverflow, "#{name} cookie overflowed with size #{options[:value].bytesize} bytes"
+ total_size = name.to_s.bytesize + options[:value].bytesize
+
+ if total_size > MAX_COOKIE_SIZE
+ raise CookieOverflow, "#{name} cookie overflowed with size #{total_size} bytes"
end
end
end
diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb
index baa7e3d27ca90..819bc4a25af66 100644
--- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb
+++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb
@@ -65,21 +65,26 @@ def render_exception(request, exception, wrapper)
content_type = Mime[:text]
end
- if api_request?(content_type)
+ if request.head?
+ render(wrapper.status_code, "", content_type)
+ elsif api_request?(content_type)
render_for_api_request(content_type, wrapper)
else
- render_for_browser_request(request, wrapper)
+ render_for_browser_request(request, wrapper, content_type)
end
else
raise exception
end
end
- def render_for_browser_request(request, wrapper)
+ def render_for_browser_request(request, wrapper, content_type)
template = create_template(request, wrapper)
file = "rescues/#{wrapper.rescue_template}"
- if request.xhr?
+ if content_type == Mime[:md]
+ body = template.render(template: file, layout: false, formats: [:text])
+ format = "text/markdown"
+ elsif request.xhr?
body = template.render(template: file, layout: false, formats: [:text])
format = "text/plain"
else
@@ -125,6 +130,7 @@ def create_template(request, wrapper)
trace_to_show: wrapper.trace_to_show,
routes_inspector: routes_inspector(wrapper),
source_extracts: wrapper.source_extracts,
+ exception_message_for_copy: compose_exception_message(wrapper).join("\n"),
)
end
@@ -138,6 +144,11 @@ def log_error(request, wrapper)
return unless logger
return if !log_rescued_responses?(request) && wrapper.rescue_response?
+ message = compose_exception_message(wrapper)
+ log_array(logger, message, request)
+ end
+
+ def compose_exception_message(wrapper)
trace = wrapper.exception_trace
message = []
@@ -166,7 +177,7 @@ def log_error(request, wrapper)
end
end
- log_array(logger, message, request)
+ message
end
def log_array(logger, lines, request)
diff --git a/actionpack/lib/action_dispatch/middleware/debug_view.rb b/actionpack/lib/action_dispatch/middleware/debug_view.rb
index 883c5104d1a05..758c0d607649b 100644
--- a/actionpack/lib/action_dispatch/middleware/debug_view.rb
+++ b/actionpack/lib/action_dispatch/middleware/debug_view.rb
@@ -55,6 +55,17 @@ def render(*)
end
end
+ def editor_url(location, line: nil)
+ if editor = ActiveSupport::Editor.current
+ line ||= location&.lineno
+ absolute_path = location&.absolute_path
+
+ if absolute_path && line && File.exist?(absolute_path)
+ editor.url_for(absolute_path, line)
+ end
+ end
+ end
+
def protect_against_forgery?
false
end
diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb
index 627186f828d9d..ab4b69288b006 100644
--- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb
+++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb
@@ -18,11 +18,12 @@ class ExceptionWrapper
"ActionController::UnknownFormat" => :not_acceptable,
"ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable,
"ActionController::MissingExactTemplate" => :not_acceptable,
- "ActionController::InvalidAuthenticityToken" => :unprocessable_entity,
- "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity,
+ "ActionController::InvalidAuthenticityToken" => ActionDispatch::Constants::UNPROCESSABLE_CONTENT,
+ "ActionController::InvalidCrossOriginRequest" => ActionDispatch::Constants::UNPROCESSABLE_CONTENT,
"ActionDispatch::Http::Parameters::ParseError" => :bad_request,
"ActionController::BadRequest" => :bad_request,
"ActionController::ParameterMissing" => :bad_request,
+ "ActionController::TooManyRequests" => :too_many_requests,
"Rack::QueryParser::ParameterTypeError" => :bad_request,
"Rack::QueryParser::InvalidParameterError" => :bad_request
)
@@ -148,15 +149,20 @@ def traces
application_trace_with_ids = []
framework_trace_with_ids = []
full_trace_with_ids = []
+ application_traces = application_trace.map(&:to_s)
+ full_trace = backtrace_cleaner&.clean_locations(backtrace, :all).presence || backtrace
full_trace.each_with_index do |trace, idx|
+ filtered_trace = backtrace_cleaner&.clean_frame(trace, :all) || trace
+
trace_with_id = {
exception_object_id: @exception.object_id,
id: idx,
- trace: trace
+ trace: trace,
+ filtered_trace: filtered_trace,
}
- if application_trace.include?(trace)
+ if application_traces.include?(filtered_trace.to_s)
application_trace_with_ids << trace_with_id
else
framework_trace_with_ids << trace_with_id
@@ -173,7 +179,7 @@ def traces
end
def self.status_code_for_exception(class_name)
- Rack::Utils.status_code(@@rescue_responses[class_name])
+ ActionDispatch::Response.rack_status_code(@@rescue_responses[class_name])
end
def show?(request)
@@ -197,7 +203,7 @@ def rescue_response?
def source_extracts
backtrace.map do |trace|
- extract_source(trace)
+ extract_source(trace).merge(trace: trace)
end
end
@@ -230,7 +236,7 @@ def exception_id
end
private
- class SourceMapLocation < DelegateClass(Thread::Backtrace::Location) # :nodoc:
+ class SourceMapLocation < ActiveSupport::Delegation::DelegateClass(Thread::Backtrace::Location) # :nodoc:
def initialize(location, template)
super(location)
@template = template
diff --git a/actionpack/lib/action_dispatch/middleware/executor.rb b/actionpack/lib/action_dispatch/middleware/executor.rb
index 285f1dc472343..bc83ecfbf3ff1 100644
--- a/actionpack/lib/action_dispatch/middleware/executor.rb
+++ b/actionpack/lib/action_dispatch/middleware/executor.rb
@@ -12,6 +12,10 @@ def initialize(app, executor)
def call(env)
state = @executor.run!(reset: true)
+ if response_finished = env["rack.response_finished"]
+ response_finished << proc { state.complete! }
+ end
+
begin
response = @app.call(env)
@@ -20,7 +24,11 @@ def call(env)
@executor.error_reporter.report(error, handled: false, source: "application.action_dispatch")
end
- returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
+ unless response_finished
+ response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
+ end
+ returned = true
+ response
rescue Exception => error
request = ActionDispatch::Request.new env
backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
@@ -28,7 +36,9 @@ def call(env)
@executor.error_reporter.report(wrapper.unwrapped_exception, handled: false, source: "application.action_dispatch")
raise
ensure
- state.complete! unless returned
+ if !returned && !response_finished
+ state.complete!
+ end
end
end
end
diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb
index 621d82f1e17ab..51a5b13d0d018 100644
--- a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb
+++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb
@@ -25,14 +25,14 @@ def initialize(public_path)
def call(env)
request = ActionDispatch::Request.new(env)
status = request.path_info[1..-1].to_i
- begin
- content_type = request.formats.first
- rescue ActionDispatch::Http::MimeNegotiation::InvalidType
- content_type = Mime[:text]
- end
+ content_type = request.formats.first
body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]) }
- render(status, content_type, body)
+ if env["action_dispatch.original_request_method"] == "HEAD"
+ render_format(status, content_type, "")
+ else
+ render(status, content_type, body)
+ end
end
private
diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb
index d665a7fdabe43..c3aaefbd1a2a5 100644
--- a/actionpack/lib/action_dispatch/middleware/remote_ip.rb
+++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb
@@ -44,6 +44,8 @@ class IpSpoofAttackError < StandardError; end
"10.0.0.0/8", # private IPv4 range 10.x.x.x
"172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255
"192.168.0.0/16", # private IPv4 range 192.168.x.x
+ "169.254.0.0/16", # link-local IPv4 range 169.254.x.x
+ "fe80::/10", # link-local IPv6 range fe80::/10
].map { |proxy| IPAddr.new(proxy) }
attr_reader :check_ip, :proxies
@@ -126,11 +128,11 @@ def initialize(req, check_ip, proxies)
# left, which was presumably set by one of those proxies.
def calculate_ip
# Set by the Rack web server, this is a single value.
- remote_addr = ips_from(@req.remote_addr).last
+ remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last
# Could be a CSV list and/or repeated headers that were concatenated.
- client_ips = ips_from(@req.client_ip).reverse!
- forwarded_ips = ips_from(@req.x_forwarded_for).reverse!
+ client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse!
+ forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse!
# `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they
# are both set, it means that either:
@@ -150,7 +152,8 @@ def calculate_ip
# We don't know which came from the proxy, and which from the user
raise IpSpoofAttackError, "IP spoofing attack?! " \
"HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \
- "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}"
+ "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}" \
+ " HTTP_FORWARDED=" + @req.forwarded_for.map { "for=#{_1}" }.join(", ").inspect if @req.forwarded_for.any?
end
# We assume these things about the IP headers:
@@ -176,7 +179,10 @@ def to_s
def ips_from(header) # :doc:
return [] unless header
# Split the comma-separated list into an array of strings.
- ips = header.strip.split(/[,\s]+/)
+ header.strip.split(/[,\s]+/)
+ end
+
+ def sanitize_ips(ips) # :doc:
ips.select! do |ip|
# Only return IPs that are valid according to the IPAddr#new method.
range = IPAddr.new(ip).to_range
diff --git a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb
index c6ee12684bfe7..191ab27624270 100644
--- a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb
+++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb
@@ -50,7 +50,7 @@ module Session
# would set the session cookie to expire automatically 14 days after creation.
# Other useful options include `:key`, `:secure`, `:httponly`, and `:same_site`.
class CookieStore < AbstractSecureStore
- class SessionId < DelegateClass(Rack::Session::SessionId)
+ class SessionId < ActiveSupport::Delegation::DelegateClass(Rack::Session::SessionId)
attr_reader :cookie_value
def initialize(session_id, cookie_value = {})
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb
new file mode 100644
index 0000000000000..66257c371927f
--- /dev/null
+++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb
@@ -0,0 +1 @@
+
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb
index 9f168357ed252..9a02e0d5ca986 100644
--- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb
+++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb
@@ -11,8 +11,9 @@