1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Adds support for configuring HTTP Feature Policy (#33439)

A HTTP feature policy is Yet Another HTTP header for instructing the
browser about which features the application intends to make use of and
to lock down access to others. This is a new security mechanism that
ensures that should an application become compromised or a third party
attempts an unexpected action, the browser will override it and maintain
the intended UX.

WICG specification: https://wicg.github.io/feature-policy/

The end result is a HTTP header that looks like the following:

```
Feature-Policy: geolocation 'none'; autoplay https://example.com
```

This will prevent the browser from using geolocation and only allow
autoplay on `https://example.com`. Full feature list can be found over
in the WICG repository[1].

As of today Chrome and Safari have public support[2] for this
functionality with Firefox working on support[3] and Edge still pending
acceptance of the suggestion[4].

#### Examples

Using an initializer

```rb
# config/initializers/feature_policy.rb
Rails.application.config.feature_policy do |f|
  f.geolocation :none
  f.camera      :none
  f.payment     "https://secure.example.com"
  f.fullscreen  :self
end
```

In a controller

```rb
class SampleController < ApplicationController
  def index
    feature_policy do |f|
      f.geolocation "https://example.com"
    end
  end
end
```

Some of you might realise that the HTTP feature policy looks pretty
close to that of a Content Security Policy; and you're right. So much so
that I used the Content Security Policy DSL from #31162 as the starting
point for this change.

This change *doesn't* introduce support for defining a feature policy on
an iframe and this has been intentionally done to split the HTTP header
and the HTML element (`iframe`) support. If this is successful, I'll
look to add that on it's own.

Full documentation on HTTP feature policies can be found at
https://wicg.github.io/feature-policy/. Google have also published[5] a
great in-depth write up of this functionality.

[1]: https://github.com/WICG/feature-policy/blob/master/features.md
[2]: https://www.chromestatus.com/feature/5694225681219584
[3]: https://bugzilla.mozilla.org/show_bug.cgi?id=1390801
[4]: https://wpdev.uservoice.com/forums/257854-microsoft-edge-developer/suggestions/33507907-support-feature-policy
[5]: https://developers.google.com/web/updates/2018/06/feature-policy
This commit is contained in:
Jacob Bednarz 2019-07-11 08:33:16 +10:00 committed by Jeremy Daer
parent 2fa21fe736
commit bf19b8774e
14 changed files with 608 additions and 1 deletions

View file

@ -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.

View file

@ -28,6 +28,7 @@ module ActionController
autoload :DefaultHeaders
autoload :EtagWithTemplateDigest
autoload :EtagWithFlash
autoload :FeaturePolicy
autoload :Flash
autoload :ForceSSL
autoload :Head

View file

@ -226,6 +226,7 @@ module ActionController
FormBuilder,
RequestForgeryProtection,
ContentSecurityPolicy,
FeaturePolicy,
ForceSSL,
Streaming,
DataStreaming,

View file

@ -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

View file

@ -43,6 +43,7 @@ module ActionDispatch
eager_autoload do
autoload_under "http" do
autoload :ContentSecurityPolicy
autoload :FeaturePolicy
autoload :Request
autoload :Response
end

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -271,7 +271,8 @@ module Rails
"action_dispatch.content_security_policy" => config.content_security_policy,
"action_dispatch.content_security_policy_report_only" => config.content_security_policy_report_only,
"action_dispatch.content_security_policy_nonce_generator" => config.content_security_policy_nonce_generator,
"action_dispatch.content_security_policy_nonce_directives" => config.content_security_policy_nonce_directives
"action_dispatch.content_security_policy_nonce_directives" => config.content_security_policy_nonce_directives,
"action_dispatch.feature_policy" => config.feature_policy,
)
end
end

View file

@ -69,6 +69,7 @@ module Rails
@autoloader = :classic
@disable_sandbox = false
@add_autoload_paths_to_load_path = true
@feature_policy = nil
end
def load_defaults(target_version)
@ -301,6 +302,14 @@ module Rails
end
end
def feature_policy(&block)
if block_given?
@feature_policy = ActionDispatch::FeaturePolicy.new(&block)
else
@feature_policy
end
end
def autoloader=(autoloader)
case autoloader
when :classic

View file

@ -68,6 +68,7 @@ module Rails
unless config.api_only
middleware.use ::ActionDispatch::ContentSecurityPolicy::Middleware
middleware.use ::ActionDispatch::FeaturePolicy::Middleware
end
middleware.use ::Rack::Head

View file

@ -0,0 +1,11 @@
# Define an application-wide HTTP feature policy. For further
# information see https://developers.google.com/web/updates/2018/06/feature-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

View file

@ -0,0 +1,191 @@
# frozen_string_literal: true
require "isolation/abstract_unit"
require "rack/test"
module ApplicationTests
class FeaturePolicyTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation
include Rack::Test::Methods
def setup
build_app
end
def teardown
teardown_app
end
test "feature policy is not enabled by default" do
controller :pages, <<-RUBY
class PagesController < ApplicationController
def index
render html: "<h1>Welcome to Rails!</h1>"
end
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
root to: "pages#index"
end
RUBY
app("development")
get "/"
assert_nil last_response.headers["Feature-Policy"]
end
test "global feature policy in an initializer" do
controller :pages, <<-RUBY
class PagesController < ApplicationController
def index
render html: "<h1>Welcome to Rails!</h1>"
end
end
RUBY
app_file "config/initializers/feature_policy.rb", <<-RUBY
Rails.application.config.feature_policy do |p|
p.geolocation :none
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
root to: "pages#index"
end
RUBY
app("development")
get "/"
assert_policy "geolocation 'none'"
end
test "override feature policy using same directive in a controller" do
controller :pages, <<-RUBY
class PagesController < ApplicationController
feature_policy do |p|
p.geolocation "https://example.com"
end
def index
render html: "<h1>Welcome to Rails!</h1>"
end
end
RUBY
app_file "config/initializers/feature_policy.rb", <<-RUBY
Rails.application.config.feature_policy do |p|
p.geolocation :none
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
root to: "pages#index"
end
RUBY
app("development")
get "/"
assert_policy "geolocation https://example.com"
end
test "override feature policy by unsetting a directive in a controller" do
controller :pages, <<-RUBY
class PagesController < ApplicationController
feature_policy do |p|
p.geolocation nil
end
def index
render html: "<h1>Welcome to Rails!</h1>"
end
end
RUBY
app_file "config/initializers/feature_policy.rb", <<-RUBY
Rails.application.config.feature_policy do |p|
p.geolocation :none
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
root to: "pages#index"
end
RUBY
app("development")
get "/"
assert_equal 200, last_response.status
assert_nil last_response.headers["Feature-Policy"]
end
test "override feature policy using different directives in a controller" do
controller :pages, <<-RUBY
class PagesController < ApplicationController
feature_policy do |p|
p.geolocation nil
p.payment "https://secure.example.com"
p.autoplay :none
end
def index
render html: "<h1>Welcome to Rails!</h1>"
end
end
RUBY
app_file "config/initializers/feature_policy.rb", <<-RUBY
Rails.application.config.feature_policy do |p|
p.geolocation :none
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
root to: "pages#index"
end
RUBY
app("development")
get "/"
assert_policy "payment https://secure.example.com; autoplay 'none'"
end
test "global feature policy added to rack app" do
app_file "config/initializers/feature_policy.rb", <<-RUBY
Rails.application.config.feature_policy do |p|
p.payment :none
end
RUBY
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
app = ->(env) {
[200, { "Content-Type" => "text/html" }, ["<p>Hello, World!</p>"]]
}
root to: app
end
RUBY
app("development")
get "/"
assert_policy "payment 'none'"
end
private
def assert_policy(expected)
assert_equal 200, last_response.status
assert_equal expected, last_response.headers["Feature-Policy"]
end
end
end

View file

@ -46,6 +46,7 @@ module ApplicationTests
"ActionDispatch::Session::CookieStore",
"ActionDispatch::Flash",
"ActionDispatch::ContentSecurityPolicy::Middleware",
"ActionDispatch::FeaturePolicy::Middleware",
"Rack::Head",
"Rack::ConditionalGet",
"Rack::ETag",