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:
commit
e20742f12b
16 changed files with 207 additions and 52 deletions
|
@ -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*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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/)
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
24
actionview/lib/action_view/helpers/csp_helper.rb
Normal file
24
actionview/lib/action_view/helpers/csp_helper.rb
Normal 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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
})()
|
})()
|
||||||
|
|
|
@ -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("<", "<").gsub(">", ">")
|
payload = JSON.generate(data).gsub("<", "<").gsub(">", ">")
|
||||||
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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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' %>
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue