Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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
  • Loading branch information
jacobbednarz authored and jeremy committed Jul 10, 2019
1 parent 2fa21fe commit bf19b87
Show file tree
Hide file tree
Showing 14 changed files with 608 additions and 1 deletion.
33 changes: 33 additions & 0 deletions 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.
Expand Down
1 change: 1 addition & 0 deletions actionpack/lib/action_controller.rb
Expand Up @@ -28,6 +28,7 @@ module ActionController
autoload :DefaultHeaders
autoload :EtagWithTemplateDigest
autoload :EtagWithFlash
autoload :FeaturePolicy
autoload :Flash
autoload :ForceSSL
autoload :Head
Expand Down
1 change: 1 addition & 0 deletions actionpack/lib/action_controller/base.rb
Expand Up @@ -226,6 +226,7 @@ def self.without_modules(*modules)
FormBuilder,
RequestForgeryProtection,
ContentSecurityPolicy,
FeaturePolicy,
ForceSSL,
Streaming,
DataStreaming,
Expand Down
46 changes: 46 additions & 0 deletions 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
1 change: 1 addition & 0 deletions actionpack/lib/action_dispatch.rb
Expand Up @@ -43,6 +43,7 @@ class IllegalStateError < StandardError
eager_autoload do
autoload_under "http" do
autoload :ContentSecurityPolicy
autoload :FeaturePolicy
autoload :Request
autoload :Response
end
Expand Down
168 changes: 168 additions & 0 deletions 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
1 change: 1 addition & 0 deletions actionpack/lib/action_dispatch/http/request.rb
Expand Up @@ -23,6 +23,7 @@ class Request
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"
Expand Down

0 comments on commit bf19b87

Please sign in to comment.