Skip to content
Permalink
Browse files

Merge pull request #32018 from rails/add-nonce-support-to-csp

Add support for automatic nonce generation for Rails UJS
  • Loading branch information...
pixeltrix committed Feb 22, 2018
1 parent da8d0c9 commit b2f0a8945956cd92dec71ec4e44715d764990a49
@@ -1,3 +1,31 @@
* Add support for automatic nonce generation for Rails UJS

Because the UJS library creates a script tag to process responses it
normally requires the script-src attribute of the content security
policy to include 'unsafe-inline'.

To work around this we generate a per-request nonce value that is
embedded in a meta tag in a similar fashion to how CSRF protection
embeds its token in a meta tag. The UJS library can then read the
nonce value and set it on the dynamically generated script tag to
enable it to execute without needing 'unsafe-inline' enabled.

Nonce generation isn't 100% safe - if your script tag is including
user generated content in someway then it may be possible to exploit
an XSS vulnerability which can take advantage of the nonce. It is
however an improvement on a blanket permission for inline scripts.

It is also possible to use the nonce within your own script tags by
using `nonce: true` to set the nonce value on the tag, e.g

<%= javascript_tag nonce: true do %>
alert('Hello, World!');
<% end %>

Fixes #31689.

*Andrew White*

* Matches behavior of `Hash#each` in `ActionController::Parameters#each`.

*Dominic Cleal*
@@ -5,6 +5,14 @@ module ContentSecurityPolicy
# TODO: Documentation
extend ActiveSupport::Concern

include AbstractController::Helpers
include AbstractController::Callbacks

included do
helper_method :content_security_policy?
helper_method :content_security_policy_nonce
end

module ClassMethods
def content_security_policy(**options, &block)
before_action(options) do
@@ -22,5 +30,15 @@ def content_security_policy_report_only(report_only = true, **options)
end
end
end

private

def content_security_policy?
request.content_security_policy
end

def content_security_policy_nonce
request.content_security_policy_nonce
end
end
end
@@ -21,6 +21,12 @@ def call(env)
return response if policy_present?(headers)

if policy = request.content_security_policy
if policy.directives["script-src"]
if nonce = request.content_security_policy_nonce
policy.directives["script-src"] << "'nonce-#{nonce}'"
end
end

headers[header_name(request)] = policy.build(request.controller_instance)
end

@@ -51,6 +57,8 @@ def policy_present?(headers)
module Request
POLICY = "action_dispatch.content_security_policy".freeze
POLICY_REPORT_ONLY = "action_dispatch.content_security_policy_report_only".freeze
NONCE_GENERATOR = "action_dispatch.content_security_policy_nonce_generator".freeze
NONCE = "action_dispatch.content_security_policy_nonce".freeze

def content_security_policy
get_header(POLICY)
@@ -67,6 +75,30 @@ def content_security_policy_report_only
def content_security_policy_report_only=(value)
set_header(POLICY_REPORT_ONLY, value)
end

def content_security_policy_nonce_generator
get_header(NONCE_GENERATOR)
end

def content_security_policy_nonce_generator=(generator)
set_header(NONCE_GENERATOR, generator)
end

def content_security_policy_nonce
if content_security_policy_nonce_generator
if nonce = get_header(NONCE)
nonce
else
set_header(NONCE, generate_content_security_policy_nonce)
end
end
end

private

def generate_content_security_policy_nonce
content_security_policy_nonce_generator.call(self)
end
end

MAPPINGS = {
@@ -253,6 +253,11 @@ class PolicyController < ActionController::Base
p.report_uri "/violations"
end

content_security_policy only: :script_src do |p|
p.default_src false
p.script_src :self
end

content_security_policy_report_only only: :report_only

def index
@@ -271,6 +276,10 @@ def report_only
head :ok
end

def script_src
head :ok
end

private
def condition?
params[:condition] == "true"
@@ -284,6 +293,7 @@ def condition?
get "/inline", to: "policy#inline"
get "/conditional", to: "policy#conditional"
get "/report-only", to: "policy#report_only"
get "/script-src", to: "policy#script_src"
end
end

@@ -298,6 +308,7 @@ def initialize(app)

def call(env)
env["action_dispatch.content_security_policy"] = POLICY
env["action_dispatch.content_security_policy_nonce_generator"] = proc { "iyhD0Yc0W+c=" }
env["action_dispatch.content_security_policy_report_only"] = false
env["action_dispatch.show_exceptions"] = false

@@ -337,6 +348,11 @@ def test_generates_report_only_content_security_policy
assert_policy "default-src 'self'; report-uri /violations", report_only: true
end

def test_adds_nonce_to_script_src_content_security_policy
get "/script-src"
assert_policy "script-src 'self' 'nonce-iyhD0Yc0W+c='"
end

private

def env_config
@@ -1,7 +1,8 @@
#= require ./csp
#= require ./csrf
#= require ./event

{ CSRFProtection, fire } = Rails
{ cspNonce, CSRFProtection, fire } = Rails

AcceptHeaders =
'*': '*/*'
@@ -65,6 +66,7 @@ processResponse = (response, type) ->
try response = JSON.parse(response)
else if type.match(/\b(?:java|ecma)script\b/)
script = document.createElement('script')
script.nonce = cspNonce()
script.text = response
document.head.appendChild(script).parentNode.removeChild(script)
else if type.match(/\b(xml|html|svg)\b/)
@@ -0,0 +1,4 @@
# Content-Security-Policy nonce for inline scripts
cspNonce = Rails.cspNonce = ->
meta = document.querySelector('meta[name=csp-nonce]')
meta and meta.content
@@ -13,6 +13,7 @@ module Helpers #:nodoc:
autoload :CacheHelper
autoload :CaptureHelper
autoload :ControllerHelper
autoload :CspHelper
autoload :CsrfHelper
autoload :DateHelper
autoload :DebugHelper
@@ -46,6 +47,7 @@ def self.eager_load!
include CacheHelper
include CaptureHelper
include ControllerHelper
include CspHelper
include CsrfHelper
include DateHelper
include DebugHelper
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module ActionView
# = Action View CSP Helper
module Helpers #:nodoc:
module CspHelper
# Returns a meta tag "csp-nonce" with the per-session nonce value
# for allowing inline <script> tags.
#
# <head>
# <%= csp_meta_tag %>
# </head>
#
# This is used by the Rails UJS helper to create dynamically
# loaded inline <script> elements.
#
def csp_meta_tag
if content_security_policy?
tag("meta", name: "csp-nonce", content: content_security_policy_nonce)
end
end
end
end
end
@@ -63,6 +63,13 @@ def escape_javascript(javascript)
# <%= javascript_tag defer: 'defer' do -%>
# alert('All is good')
# <% end -%>
#
# If you have a content security policy enabled then you can add an automatic
# nonce value by passing +nonce: true+ as part of +html_options+. Example:
#
# <%= javascript_tag nonce: true do -%>
# alert('All is good')
# <% end -%>
def javascript_tag(content_or_options_with_block = nil, html_options = {}, &block)
content =
if block_given?
@@ -72,6 +79,10 @@ def javascript_tag(content_or_options_with_block = nil, html_options = {}, &bloc
content_or_options_with_block
end

if html_options[:nonce] == true
html_options[:nonce] = content_security_policy_nonce
end

content_tag("script".freeze, javascript_cdata_section(content), html_options)
end

@@ -8,7 +8,6 @@ module('call-ajax', {
})

asyncTest('call ajax without "ajax:beforeSend"', 1, function() {

var link = $('#qunit-fixture a')
link.bindNative('click', function() {
Rails.ajax({
@@ -21,7 +20,7 @@ asyncTest('call ajax without "ajax:beforeSend"', 1, function() {
})

link.triggerNative('click')
setTimeout(function() { start() }, 13)
setTimeout(function() { start() }, 50)
})

})()
@@ -23,18 +23,30 @@ class Server < Rails::Application
config.public_file_server.enabled = true
config.logger = Logger.new(STDOUT)
config.log_level = :error

config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https
end

config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) }
end
end

module TestsHelper
def test_to(*names)
names = ["/vendor/qunit.js", "settings"] + names
names.map { |name| script_tag name }.join("\n").html_safe
end
names = names.map { |name| "/test/#{name}.js" }
names = %w[/vendor/qunit.js /test/settings.js] + names

def script_tag(src)
src = "/test/#{src}.js" unless src.index("/")
%(<script src="#{src}" type="text/javascript"></script>).html_safe
capture do
names.each do |name|
concat(javascript_include_tag(name))
end
end
end
end

@@ -56,7 +68,7 @@ def echo
elsif params[:iframe]
payload = JSON.generate(data).gsub("<", "&lt;").gsub(">", "&gt;")
html = <<-HTML
<script>
<script nonce="#{request.content_security_policy_nonce}">
if (window.top && window.top !== window)
window.top.jQuery.event.trigger('iframe:loaded', #{payload})
</script>
@@ -2,9 +2,10 @@
<html id="html">
<head>
<title><%= @title %></title>
<%= csp_meta_tag %>
<link href="/vendor/qunit.css" media="screen" rel="stylesheet" type="text/css" media="screen, projection" />
<script src="/vendor/jquery-2.2.0.js" type="text/javascript"></script>
<script>
<%= javascript_tag nonce: true do %>
// This is for test in override.js.
// Must go before rails-ujs.
document.addEventListener('rails:attachBindings', function() {
@@ -15,8 +16,8 @@
e.preventDefault();
});
});
</script>
<%= script_tag "/rails-ujs.js" %>
<% end %>
<%= javascript_include_tag "/rails-ujs.js" %>
</head>

<body id="body">
@@ -268,7 +268,8 @@ def env_config
"action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest,
"action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations,
"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_report_only" => config.content_security_policy_report_only,
"action_dispatch.content_security_policy_nonce_generator" => config.content_security_policy_nonce_generator
)
end
end
Oops, something went wrong.

0 comments on commit b2f0a89

Please sign in to comment.
You can’t perform that action at this time.