diff options
Diffstat (limited to 'actionpack')
-rw-r--r-- | actionpack/CHANGELOG.md | 33 | ||||
-rw-r--r-- | actionpack/lib/action_controller.rb | 1 | ||||
-rw-r--r-- | actionpack/lib/action_controller/base.rb | 1 | ||||
-rw-r--r-- | actionpack/lib/action_controller/metal/feature_policy.rb | 46 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch.rb | 1 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/feature_policy.rb | 168 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/request.rb | 1 | ||||
-rw-r--r-- | actionpack/test/dispatch/feature_policy_test.rb | 142 |
8 files changed, 393 insertions, 0 deletions
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 0dd170fd28..72d6c46782 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,36 @@ +* 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/action_controller.rb b/actionpack/lib/action_controller.rb index 29d61c3ceb..dfa49fcc36 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -28,6 +28,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/feature_policy.rb b/actionpack/lib/action_controller/metal/feature_policy.rb new file mode 100644 index 0000000000..eecca20dda --- /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_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/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/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 |