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

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

Add support for automatic nonce generation for Rails UJS
This commit is contained in:
Andrew White 2018-02-22 15:32:23 +00:00 committed by GitHub
commit e20742f12b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 207 additions and 52 deletions

View file

@ -1,5 +1,33 @@
## Rails 6.0.0.alpha (Unreleased) ## ## Rails 6.0.0.alpha (Unreleased) ##
* 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*
* Rails 6 requires Ruby 2.4.1 or newer. * Rails 6 requires Ruby 2.4.1 or newer.
*Jeremy Daer* *Jeremy Daer*

View file

@ -5,6 +5,14 @@ module ActionController #:nodoc:
# TODO: Documentation # TODO: Documentation
extend ActiveSupport::Concern 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 module ClassMethods
def content_security_policy(**options, &block) def content_security_policy(**options, &block)
before_action(options) do before_action(options) do
@ -22,5 +30,15 @@ module ActionController #:nodoc:
end end
end 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
end end

View file

@ -21,6 +21,12 @@ module ActionDispatch #:nodoc:
return response if policy_present?(headers) return response if policy_present?(headers)
if policy = request.content_security_policy 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) headers[header_name(request)] = policy.build(request.controller_instance)
end end
@ -51,6 +57,8 @@ module ActionDispatch #:nodoc:
module Request module Request
POLICY = "action_dispatch.content_security_policy".freeze POLICY = "action_dispatch.content_security_policy".freeze
POLICY_REPORT_ONLY = "action_dispatch.content_security_policy_report_only".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 def content_security_policy
get_header(POLICY) get_header(POLICY)
@ -67,6 +75,30 @@ module ActionDispatch #:nodoc:
def content_security_policy_report_only=(value) def content_security_policy_report_only=(value)
set_header(POLICY_REPORT_ONLY, value) set_header(POLICY_REPORT_ONLY, value)
end 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 end
MAPPINGS = { MAPPINGS = {

View file

@ -253,6 +253,11 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
p.report_uri "/violations" p.report_uri "/violations"
end 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 content_security_policy_report_only only: :report_only
def index def index
@ -271,6 +276,10 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
head :ok head :ok
end end
def script_src
head :ok
end
private private
def condition? def condition?
params[:condition] == "true" params[:condition] == "true"
@ -284,6 +293,7 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
get "/inline", to: "policy#inline" get "/inline", to: "policy#inline"
get "/conditional", to: "policy#conditional" get "/conditional", to: "policy#conditional"
get "/report-only", to: "policy#report_only" get "/report-only", to: "policy#report_only"
get "/script-src", to: "policy#script_src"
end end
end end
@ -298,6 +308,7 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
def call(env) def call(env)
env["action_dispatch.content_security_policy"] = POLICY 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.content_security_policy_report_only"] = false
env["action_dispatch.show_exceptions"] = false env["action_dispatch.show_exceptions"] = false
@ -337,6 +348,11 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
assert_policy "default-src 'self'; report-uri /violations", report_only: true assert_policy "default-src 'self'; report-uri /violations", report_only: true
end end
def test_adds_nonce_to_script_src_content_security_policy
get "/script-src"
assert_policy "script-src 'self' 'nonce-iyhD0Yc0W+c='"
end
private private
def env_config def env_config

View file

@ -1,7 +1,8 @@
#= require ./csp
#= require ./csrf #= require ./csrf
#= require ./event #= require ./event
{ CSRFProtection, fire } = Rails { cspNonce, CSRFProtection, fire } = Rails
AcceptHeaders = AcceptHeaders =
'*': '*/*' '*': '*/*'
@ -65,6 +66,7 @@ processResponse = (response, type) ->
try response = JSON.parse(response) try response = JSON.parse(response)
else if type.match(/\b(?:java|ecma)script\b/) else if type.match(/\b(?:java|ecma)script\b/)
script = document.createElement('script') script = document.createElement('script')
script.nonce = cspNonce()
script.text = response script.text = response
document.head.appendChild(script).parentNode.removeChild(script) document.head.appendChild(script).parentNode.removeChild(script)
else if type.match(/\b(xml|html|svg)\b/) else if type.match(/\b(xml|html|svg)\b/)

View file

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

View file

@ -13,6 +13,7 @@ module ActionView #:nodoc:
autoload :CacheHelper autoload :CacheHelper
autoload :CaptureHelper autoload :CaptureHelper
autoload :ControllerHelper autoload :ControllerHelper
autoload :CspHelper
autoload :CsrfHelper autoload :CsrfHelper
autoload :DateHelper autoload :DateHelper
autoload :DebugHelper autoload :DebugHelper
@ -46,6 +47,7 @@ module ActionView #:nodoc:
include CacheHelper include CacheHelper
include CaptureHelper include CaptureHelper
include ControllerHelper include ControllerHelper
include CspHelper
include CsrfHelper include CsrfHelper
include DateHelper include DateHelper
include DebugHelper include DebugHelper

View file

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

View file

@ -63,6 +63,13 @@ module ActionView
# <%= javascript_tag defer: 'defer' do -%> # <%= javascript_tag defer: 'defer' do -%>
# alert('All is good') # alert('All is good')
# <% end -%> # <% 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) def javascript_tag(content_or_options_with_block = nil, html_options = {}, &block)
content = content =
if block_given? if block_given?
@ -72,6 +79,10 @@ module ActionView
content_or_options_with_block content_or_options_with_block
end end
if html_options[:nonce] == true
html_options[:nonce] = content_security_policy_nonce
end
content_tag("script".freeze, javascript_cdata_section(content), html_options) content_tag("script".freeze, javascript_cdata_section(content), html_options)
end end

View file

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

View file

@ -23,18 +23,30 @@ module UJS
config.public_file_server.enabled = true config.public_file_server.enabled = true
config.logger = Logger.new(STDOUT) config.logger = Logger.new(STDOUT)
config.log_level = :error 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
end end
module TestsHelper module TestsHelper
def test_to(*names) def test_to(*names)
names = ["/vendor/qunit.js", "settings"] + names names = names.map { |name| "/test/#{name}.js" }
names.map { |name| script_tag name }.join("\n").html_safe names = %w[/vendor/qunit.js /test/settings.js] + names
end
def script_tag(src) capture do
src = "/test/#{src}.js" unless src.index("/") names.each do |name|
%(<script src="#{src}" type="text/javascript"></script>).html_safe concat(javascript_include_tag(name))
end
end
end end
end end
@ -56,7 +68,7 @@ class TestsController < ActionController::Base
elsif params[:iframe] elsif params[:iframe]
payload = JSON.generate(data).gsub("<", "&lt;").gsub(">", "&gt;") payload = JSON.generate(data).gsub("<", "&lt;").gsub(">", "&gt;")
html = <<-HTML html = <<-HTML
<script> <script nonce="#{request.content_security_policy_nonce}">
if (window.top && window.top !== window) if (window.top && window.top !== window)
window.top.jQuery.event.trigger('iframe:loaded', #{payload}) window.top.jQuery.event.trigger('iframe:loaded', #{payload})
</script> </script>

View file

@ -2,9 +2,10 @@
<html id="html"> <html id="html">
<head> <head>
<title><%= @title %></title> <title><%= @title %></title>
<%= csp_meta_tag %>
<link href="/vendor/qunit.css" media="screen" rel="stylesheet" type="text/css" media="screen, projection" /> <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 src="/vendor/jquery-2.2.0.js" type="text/javascript"></script>
<script> <%= javascript_tag nonce: true do %>
// This is for test in override.js. // This is for test in override.js.
// Must go before rails-ujs. // Must go before rails-ujs.
document.addEventListener('rails:attachBindings', function() { document.addEventListener('rails:attachBindings', function() {
@ -15,8 +16,8 @@
e.preventDefault(); e.preventDefault();
}); });
}); });
</script> <% end %>
<%= script_tag "/rails-ujs.js" %> <%= javascript_include_tag "/rails-ujs.js" %>
</head> </head>
<body id="body"> <body id="body">

View file

@ -268,7 +268,8 @@ module Rails
"action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest, "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest,
"action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations, "action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations,
"action_dispatch.content_security_policy" => config.content_security_policy, "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
end end

View file

@ -17,7 +17,7 @@ module Rails
:session_options, :time_zone, :reload_classes_only_on_change, :session_options, :time_zone, :reload_classes_only_on_change,
:beginning_of_week, :filter_redirect, :x, :enable_dependency_loading, :beginning_of_week, :filter_redirect, :x, :enable_dependency_loading,
:read_encrypted_secrets, :log_level, :content_security_policy_report_only, :read_encrypted_secrets, :log_level, :content_security_policy_report_only,
:require_master_key :content_security_policy_nonce_generator, :require_master_key
attr_reader :encoding, :api_only, :loaded_config_version attr_reader :encoding, :api_only, :loaded_config_version
@ -57,6 +57,7 @@ module Rails
@read_encrypted_secrets = false @read_encrypted_secrets = false
@content_security_policy = nil @content_security_policy = nil
@content_security_policy_report_only = false @content_security_policy_report_only = false
@content_security_policy_nonce_generator = nil
@require_master_key = false @require_master_key = false
@loaded_config_version = nil @loaded_config_version = nil
end end

View file

@ -3,6 +3,7 @@
<head> <head>
<title><%= camelized %></title> <title><%= camelized %></title>
<%%= csrf_meta_tags %> <%%= csrf_meta_tags %>
<%%= csp_meta_tag %>
<%- if options[:skip_javascript] -%> <%- if options[:skip_javascript] -%>
<%%= stylesheet_link_tag 'application', media: 'all' %> <%%= stylesheet_link_tag 'application', media: 'all' %>

View file

@ -10,12 +10,15 @@
# policy.img_src :self, :https, :data # policy.img_src :self, :https, :data
# policy.object_src :none # policy.object_src :none
# policy.script_src :self, :https # policy.script_src :self, :https
# policy.style_src :self, :https, :unsafe_inline # policy.style_src :self, :https
# # Specify URI for violation reports # # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint" # # policy.report_uri "/csp-violation-report-endpoint"
# end # end
# If you are using UJS then enable automatic nonce generation
# Rails.application.config.content_security_policy_nonce_generator = -> { SecureRandom.base64(16) }
# Report CSP violations to a specified URI # Report CSP violations to a specified URI
# For further information see the following documentation: # For further information see the following documentation:
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only