diff options
Diffstat (limited to 'actionpack')
21 files changed, 491 insertions, 16 deletions
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 0dd170fd28..9a6bd4bb45 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,40 @@ +* Reduced log noise handling ActionController::RoutingErrors. + + *Alberto Fernández-Capel* + +* Add DSL for configuring HTTP Feature Policy + + This new DSL provides a way to configure a HTTP Feature Policy at a + global or per-controller level. Full details of HTTP Feature Policy + specification and guidelines can be found at MDN: + + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy + + Example global policy + + ``` + Rails.application.config.feature_policy do |f| + f.camera :none + f.gyroscope :none + f.microphone :none + f.usb :none + f.fullscreen :self + f.payment :self, "https://secure.example.com" + end + ``` + + Example controller level policy + + ``` + class PagesController < ApplicationController + feature_policy do |p| + p.geolocation "https://example.com" + end + end + ``` + + *Jacob Bednarz* + * Add the ability to set the CSP nonce only to the specified directives. Fixes #35137. diff --git a/actionpack/lib/abstract_controller.rb b/actionpack/lib/abstract_controller.rb index 3a98931167..d1ff62a032 100644 --- a/actionpack/lib/abstract_controller.rb +++ b/actionpack/lib/abstract_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "action_pack" +require "active_support" require "active_support/rails" require "active_support/i18n" diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index 29d61c3ceb..22dc229599 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "active_support/rails" require "abstract_controller" require "action_dispatch" require "action_controller/metal/live" @@ -28,6 +27,7 @@ module ActionController autoload :DefaultHeaders autoload :EtagWithTemplateDigest autoload :EtagWithFlash + autoload :FeaturePolicy autoload :Flash autoload :ForceSSL autoload :Head diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index 2e565d5d44..63c138af55 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -226,6 +226,7 @@ module ActionController FormBuilder, RequestForgeryProtection, ContentSecurityPolicy, + FeaturePolicy, ForceSSL, Streaming, DataStreaming, diff --git a/actionpack/lib/action_controller/metal/data_streaming.rb b/actionpack/lib/action_controller/metal/data_streaming.rb index 9ef4f50df1..879745a895 100644 --- a/actionpack/lib/action_controller/metal/data_streaming.rb +++ b/actionpack/lib/action_controller/metal/data_streaming.rb @@ -53,7 +53,7 @@ module ActionController #:nodoc: # # Show a 404 page in the browser: # - # send_file '/path/to/404.html', type: 'text/html; charset=utf-8', status: 404 + # send_file '/path/to/404.html', type: 'text/html; charset=utf-8', disposition: 'inline', status: 404 # # Read about the other Content-* HTTP headers if you'd like to # provide the user with more information (such as Content-Description) in diff --git a/actionpack/lib/action_controller/metal/feature_policy.rb b/actionpack/lib/action_controller/metal/feature_policy.rb new file mode 100644 index 0000000000..a627eabea6 --- /dev/null +++ b/actionpack/lib/action_controller/metal/feature_policy.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module ActionController #:nodoc: + # HTTP Feature Policy is a web standard for defining a mechanism to + # allow and deny the use of browser features in its own context, and + # in content within any <iframe> elements in the document. + # + # Full details of HTTP Feature Policy specification and guidelines can + # be found at MDN: + # + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy + # + # Examples of usage: + # + # # Global policy + # Rails.application.config.feature_policy do |f| + # f.camera :none + # f.gyroscope :none + # f.microphone :none + # f.usb :none + # f.fullscreen :self + # f.payment :self, "https://secure.example.com" + # end + # + # # Controller level policy + # class PagesController < ApplicationController + # feature_policy do |p| + # p.geolocation "https://example.com" + # end + # end + module FeaturePolicy + extend ActiveSupport::Concern + + module ClassMethods + def feature_policy(**options, &block) + before_action(options) do + if block_given? + policy = request.feature_policy.clone + yield policy + request.feature_policy = policy + end + end + end + end + end +end diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index 6a07a73d94..6fbd52dd51 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -259,6 +259,11 @@ module ActionController @parameters == other end end + alias eql? == + + def hash + [@parameters.hash, @permitted].hash + end # Returns a safe <tt>ActiveSupport::HashWithIndifferentAccess</tt> # representation of the parameters with all unpermitted keys removed. diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index 6a4ba9af4a..67d303a368 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -43,6 +43,7 @@ module ActionDispatch eager_autoload do autoload_under "http" do autoload :ContentSecurityPolicy + autoload :FeaturePolicy autoload :Request autoload :Response end diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb index 7dedecef34..9c430b57e3 100644 --- a/actionpack/lib/action_dispatch/http/content_security_policy.rb +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -137,7 +137,11 @@ module ActionDispatch #:nodoc: object_src: "object-src", prefetch_src: "prefetch-src", script_src: "script-src", + script_src_attr: "script-src-attr", + script_src_elem: "script-src-elem", style_src: "style-src", + style_src_attr: "style-src-attr", + style_src_elem: "style-src-elem", worker_src: "worker-src" }.freeze diff --git a/actionpack/lib/action_dispatch/http/feature_policy.rb b/actionpack/lib/action_dispatch/http/feature_policy.rb new file mode 100644 index 0000000000..592b6e4393 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/feature_policy.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/deep_dup" + +module ActionDispatch #:nodoc: + class FeaturePolicy + class Middleware + CONTENT_TYPE = "Content-Type" + POLICY = "Feature-Policy" + + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + _, headers, _ = response = @app.call(env) + + return response unless html_response?(headers) + return response if policy_present?(headers) + + if policy = request.feature_policy + headers[POLICY] = policy.build(request.controller_instance) + end + + if policy_empty?(policy) + headers.delete(POLICY) + end + + response + end + + private + def html_response?(headers) + if content_type = headers[CONTENT_TYPE] + content_type =~ /html/ + end + end + + def policy_present?(headers) + headers[POLICY] + end + + def policy_empty?(policy) + policy.try(:directives) && policy.directives.empty? + end + end + + module Request + POLICY = "action_dispatch.feature_policy" + + def feature_policy + get_header(POLICY) + end + + def feature_policy=(policy) + set_header(POLICY, policy) + end + end + + MAPPINGS = { + self: "'self'", + none: "'none'", + }.freeze + + # List of available features can be found at + # https://github.com/WICG/feature-policy/blob/master/features.md#policy-controlled-features + DIRECTIVES = { + accelerometer: "accelerometer", + ambient_light_sensor: "ambient-light-sensor", + autoplay: "autoplay", + camera: "camera", + encrypted_media: "encrypted-media", + fullscreen: "fullscreen", + geolocation: "geolocation", + gyroscope: "gyroscope", + magnetometer: "magnetometer", + microphone: "microphone", + midi: "midi", + payment: "payment", + picture_in_picture: "picture-in-picture", + speaker: "speaker", + usb: "usb", + vibrate: "vibrate", + vr: "vr", + }.freeze + + private_constant :MAPPINGS, :DIRECTIVES + + attr_reader :directives + + def initialize + @directives = {} + yield self if block_given? + end + + def initialize_copy(other) + @directives = other.directives.deep_dup + end + + DIRECTIVES.each do |name, directive| + define_method(name) do |*sources| + if sources.first + @directives[directive] = apply_mappings(sources) + else + @directives.delete(directive) + end + end + end + + def build(context = nil) + build_directives(context).compact.join("; ") + end + + private + def apply_mappings(sources) + sources.map do |source| + case source + when Symbol + apply_mapping(source) + when String, Proc + source + else + raise ArgumentError, "Invalid HTTP feature policy source: #{source.inspect}" + end + end + end + + def apply_mapping(source) + MAPPINGS.fetch(source) do + raise ArgumentError, "Unknown HTTP feature policy source mapping: #{source.inspect}" + end + end + + def build_directives(context) + @directives.map do |directive, sources| + if sources.is_a?(Array) + "#{directive} #{build_directive(sources, context).join(' ')}" + elsif sources + directive + else + nil + end + end + end + + def build_directive(sources, context) + sources.map { |source| resolve_source(source, context) } + end + + def resolve_source(source, context) + case source + when String + source + when Symbol + source.to_s + when Proc + if context.nil? + raise RuntimeError, "Missing context for the dynamic feature policy source: #{source.inspect}" + else + context.instance_exec(&source) + end + else + raise RuntimeError, "Unexpected feature policy source: #{source.inspect}" + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb index 44f23940d3..4ac7c5c2bd 100644 --- a/actionpack/lib/action_dispatch/http/request.rb +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -23,6 +23,7 @@ module ActionDispatch include ActionDispatch::Http::FilterParameters include ActionDispatch::Http::URL include ActionDispatch::ContentSecurityPolicy::Request + include ActionDispatch::FeaturePolicy::Request include Rack::Request::Env autoload :Session, "action_dispatch/request/session" diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index f8937a2faf..e546d1c11f 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -137,9 +137,7 @@ module ActionDispatch return unless logger exception = wrapper.exception - - trace = wrapper.application_trace - trace = wrapper.framework_trace if trace.empty? + trace = wrapper.exception_trace ActiveSupport::Deprecation.silence do message = [] diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index 2da0ef9600..e4a2a51c57 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -36,18 +36,23 @@ module ActionDispatch "ActionView::Template::Error" ] + cattr_accessor :silent_exceptions, default: [ + "ActionController::RoutingError" + ] + attr_reader :backtrace_cleaner, :exception, :wrapped_causes, :line_number, :file def initialize(backtrace_cleaner, exception) @backtrace_cleaner = backtrace_cleaner @exception = exception + @exception_class_name = @exception.class.name @wrapped_causes = wrapped_causes_for(exception, backtrace_cleaner) expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError) end def unwrapped_exception - if wrapper_exceptions.include?(exception.class.to_s) + if wrapper_exceptions.include?(@exception_class_name) exception.cause else exception @@ -55,13 +60,19 @@ module ActionDispatch end def rescue_template - @@rescue_templates[@exception.class.name] + @@rescue_templates[@exception_class_name] end def status_code self.class.status_code_for_exception(unwrapped_exception.class.name) end + def exception_trace + trace = application_trace + trace = framework_trace if trace.empty? && !silent_exceptions.include?(@exception_class_name) + trace + end + def application_trace clean_backtrace(:silent) end diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 66f90980b9..2e09aed41d 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -54,11 +54,5 @@ module ActionDispatch ActionDispatch.test_app = app end - - initializer "action_dispatch.system_tests" do |app| - ActiveSupport.on_load(:action_dispatch_system_test_case) do - include app.routes.url_helpers - end - end end end diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb index 29864c0f8e..9772beb8aa 100644 --- a/actionpack/lib/action_dispatch/system_test_case.rb +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -119,6 +119,11 @@ module ActionDispatch def initialize(*) # :nodoc: super self.class.driver.use + @proxy_route = if ActionDispatch.test_app + Class.new { include ActionDispatch.test_app.routes.url_helpers }.new + else + nil + end end def self.start_application # :nodoc: @@ -163,6 +168,14 @@ module ActionDispatch default_url_options.merge(host: Capybara.app_host) end + def method_missing(method, *args, &block) + if @proxy_route.respond_to?(method) + @proxy_route.send(method, *args, &block) + else + super + end + end + ActiveSupport.run_load_hooks(:action_dispatch_system_test_case, self) end diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index bb8b43ad4d..c5f8b816a4 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -3,7 +3,6 @@ require "stringio" require "uri" require "active_support/core_ext/kernel/singleton_class" -require "active_support/core_ext/object/try" require "rack/test" require "minitest" diff --git a/actionpack/test/controller/parameters/accessors_test.rb b/actionpack/test/controller/parameters/accessors_test.rb index cb49eeb1b7..3d1538ff64 100644 --- a/actionpack/test/controller/parameters/accessors_test.rb +++ b/actionpack/test/controller/parameters/accessors_test.rb @@ -284,12 +284,14 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params1 = ActionController::Parameters.new(a: 1, b: 2) params2 = ActionController::Parameters.new(a: 1, b: 2) assert(params1 == params2) + assert(params1.hash == params2.hash) end test "is equal to Parameters instance with same permitted params" do params1 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) params2 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) assert(params1 == params2) + assert(params1.hash == params2.hash) end test "is equal to Parameters instance with same different source params, but same permitted params" do @@ -297,6 +299,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1, c: 3).permit(:a) assert(params1 == params2) assert(params2 == params1) + assert(params1.hash == params2.hash) + assert(params2.hash == params1.hash) end test "is not equal to an unpermitted Parameters instance with same params" do @@ -304,6 +308,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1) assert(params1 != params2) assert(params2 != params1) + assert(params1.hash != params2.hash) + assert(params2.hash != params1.hash) end test "is not equal to Parameters instance with different permitted params" do @@ -311,6 +317,8 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params2 = ActionController::Parameters.new(a: 1, b: 2).permit(:a) assert(params1 != params2) assert(params2 != params1) + assert(params1.hash != params2.hash) + assert(params2.hash != params1.hash) end test "equality with simple types works" do diff --git a/actionpack/test/controller/resources_test.rb b/actionpack/test/controller/resources_test.rb index 1e42a3a90d..339025ec52 100644 --- a/actionpack/test/controller/resources_test.rb +++ b/actionpack/test/controller/resources_test.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "abstract_unit" -require "active_support/core_ext/object/try" require "active_support/core_ext/object/with_options" require "active_support/core_ext/array/extract_options" @@ -1249,7 +1248,7 @@ class ResourcesTest < ActionController::TestCase shallow_path = "/#{options[:shallow] ? options[:namespace] : options[:path_prefix]}#{path}" full_path = "/#{options[:path_prefix]}#{path}" name_prefix = options[:name_prefix] - shallow_prefix = options[:shallow] ? options[:namespace].try(:gsub, /\//, "_") : options[:name_prefix] + shallow_prefix = options[:shallow] ? options[:namespace]&.gsub(/\//, "_") : options[:name_prefix] new_action = "new" edit_action = "edit" diff --git a/actionpack/test/dispatch/content_security_policy_test.rb b/actionpack/test/dispatch/content_security_policy_test.rb index a4634626bb..3d60dc1661 100644 --- a/actionpack/test/dispatch/content_security_policy_test.rb +++ b/actionpack/test/dispatch/content_security_policy_test.rb @@ -128,12 +128,36 @@ class ContentSecurityPolicyTest < ActiveSupport::TestCase @policy.script_src false assert_no_match %r{script-src}, @policy.build + @policy.script_src_attr :self + assert_match %r{script-src-attr 'self'}, @policy.build + + @policy.script_src_attr false + assert_no_match %r{script-src-attr}, @policy.build + + @policy.script_src_elem :self + assert_match %r{script-src-elem 'self'}, @policy.build + + @policy.script_src_elem false + assert_no_match %r{script-src-elem}, @policy.build + @policy.style_src :self assert_match %r{style-src 'self'}, @policy.build @policy.style_src false assert_no_match %r{style-src}, @policy.build + @policy.style_src_attr :self + assert_match %r{style-src-attr 'self'}, @policy.build + + @policy.style_src_attr false + assert_no_match %r{style-src-attr}, @policy.build + + @policy.style_src_elem :self + assert_match %r{style-src-elem 'self'}, @policy.build + + @policy.style_src_elem false + assert_no_match %r{style-src-elem}, @policy.build + @policy.worker_src :self assert_match %r{worker-src 'self'}, @policy.build diff --git a/actionpack/test/dispatch/debug_exceptions_test.rb b/actionpack/test/dispatch/debug_exceptions_test.rb index 68817ccdea..fa629bc761 100644 --- a/actionpack/test/dispatch/debug_exceptions_test.rb +++ b/actionpack/test/dispatch/debug_exceptions_test.rb @@ -466,6 +466,8 @@ class DebugExceptionsTest < ActionDispatch::IntegrationTest end test "logs exception backtrace when all lines silenced" do + @app = DevelopmentApp + output = StringIO.new backtrace_cleaner = ActiveSupport::BacktraceCleaner.new backtrace_cleaner.add_silencer { true } @@ -478,6 +480,27 @@ class DebugExceptionsTest < ActionDispatch::IntegrationTest assert_operator((output.rewind && output.read).lines.count, :>, 10) end + test "doesn't log the framework backtrace when error type is a routing error" do + @app = ProductionApp + + output = StringIO.new + backtrace_cleaner = ActiveSupport::BacktraceCleaner.new + backtrace_cleaner.add_silencer { true } + + env = { "action_dispatch.show_exceptions" => true, + "action_dispatch.logger" => Logger.new(output), + "action_dispatch.backtrace_cleaner" => backtrace_cleaner } + + assert_raises ActionController::RoutingError do + get "/pass", headers: env + end + + log = output.rewind && output.read + + assert_includes log, "ActionController::RoutingError (No route matches [GET] \"/pass\")" + assert_equal 3, log.lines.count + end + test "display backtrace when error type is SyntaxError" do @app = DevelopmentApp diff --git a/actionpack/test/dispatch/feature_policy_test.rb b/actionpack/test/dispatch/feature_policy_test.rb new file mode 100644 index 0000000000..ebcc8a8b6d --- /dev/null +++ b/actionpack/test/dispatch/feature_policy_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class FeaturePolicyTest < ActiveSupport::TestCase + def setup + @policy = ActionDispatch::FeaturePolicy.new + end + + def test_mappings + @policy.midi :self + assert_equal "midi 'self'", @policy.build + + @policy.midi :none + assert_equal "midi 'none'", @policy.build + end + + def test_multiple_sources_for_a_single_directive + @policy.geolocation :self, "https://example.com" + assert_equal "geolocation 'self' https://example.com", @policy.build + end + + def test_single_directive_for_multiple_directives + @policy.geolocation :self + @policy.usb :none + assert_equal "geolocation 'self'; usb 'none'", @policy.build + end + + def test_multiple_directives_for_multiple_directives + @policy.geolocation :self, "https://example.com" + @policy.usb :none, "https://example.com" + assert_equal "geolocation 'self' https://example.com; usb 'none' https://example.com", @policy.build + end + + def test_invalid_directive_source + exception = assert_raises(ArgumentError) do + @policy.vr [:non_existent] + end + + assert_equal "Invalid HTTP feature policy source: [:non_existent]", exception.message + end +end + +class FeaturePolicyIntegrationTest < ActionDispatch::IntegrationTest + class PolicyController < ActionController::Base + feature_policy only: :index do |f| + f.gyroscope :none + end + + feature_policy only: :sample_controller do |f| + f.gyroscope nil + f.usb :self + end + + feature_policy only: :multiple_directives do |f| + f.gyroscope nil + f.usb :self + f.autoplay "https://example.com" + f.payment "https://secure.example.com" + end + + def index + head :ok + end + + def sample_controller + head :ok + end + + def multiple_directives + head :ok + end + end + + ROUTES = ActionDispatch::Routing::RouteSet.new + ROUTES.draw do + scope module: "feature_policy_integration_test" do + get "/", to: "policy#index" + get "/sample_controller", to: "policy#sample_controller" + get "/multiple_directives", to: "policy#multiple_directives" + end + end + + POLICY = ActionDispatch::FeaturePolicy.new do |p| + p.gyroscope :self + end + + class PolicyConfigMiddleware + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.feature_policy"] = POLICY + env["action_dispatch.show_exceptions"] = false + + @app.call(env) + end + end + + APP = build_app(ROUTES) do |middleware| + middleware.use PolicyConfigMiddleware + middleware.use ActionDispatch::FeaturePolicy::Middleware + end + + def app + APP + end + + def test_generates_feature_policy_header + get "/" + assert_policy "gyroscope 'none'" + end + + def test_generates_per_controller_feature_policy_header + get "/sample_controller" + assert_policy "usb 'self'" + end + + def test_generates_multiple_directives_feature_policy_header + get "/multiple_directives" + assert_policy "usb 'self'; autoplay https://example.com; payment https://secure.example.com" + end + + private + def env_config + Rails.application.env_config + end + + def feature_policy + env_config["action_dispatch.feature_policy"] + end + + def feature_policy=(policy) + env_config["action_dispatch.feature_policy"] = policy + end + + def assert_policy(expected) + assert_response :success + assert_equal expected, response.headers["Feature-Policy"] + end +end |