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) ##
|
||||
|
||||
* 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.
|
||||
|
||||
*Jeremy Daer*
|
||||
|
|
|
@ -5,6 +5,14 @@ module ActionController #:nodoc:
|
|||
# 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 @@ module ActionController #:nodoc:
|
|||
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 @@ module ActionDispatch #:nodoc:
|
|||
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 @@ module ActionDispatch #:nodoc:
|
|||
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 @@ module ActionDispatch #:nodoc:
|
|||
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 ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
|
|||
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 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
|
|||
head :ok
|
||||
end
|
||||
|
||||
def script_src
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
def condition?
|
||||
params[:condition] == "true"
|
||||
|
@ -284,6 +293,7 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
|
|||
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 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
|
|||
|
||||
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 @@ class ContentSecurityPolicyIntegrationTest < ActionDispatch::IntegrationTest
|
|||
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 ActionView #:nodoc:
|
|||
autoload :CacheHelper
|
||||
autoload :CaptureHelper
|
||||
autoload :ControllerHelper
|
||||
autoload :CspHelper
|
||||
autoload :CsrfHelper
|
||||
autoload :DateHelper
|
||||
autoload :DebugHelper
|
||||
|
@ -46,6 +47,7 @@ module ActionView #:nodoc:
|
|||
include CacheHelper
|
||||
include CaptureHelper
|
||||
include ControllerHelper
|
||||
include CspHelper
|
||||
include CsrfHelper
|
||||
include DateHelper
|
||||
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 -%>
|
||||
# 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 @@ module ActionView
|
|||
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 @@ module UJS
|
|||
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 @@ class TestsController < ActionController::Base
|
|||
elsif params[:iframe]
|
||||
payload = JSON.generate(data).gsub("<", "<").gsub(">", ">")
|
||||
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 @@ module Rails
|
|||
"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
|
||||
|
|
|
@ -17,7 +17,7 @@ module Rails
|
|||
:session_options, :time_zone, :reload_classes_only_on_change,
|
||||
:beginning_of_week, :filter_redirect, :x, :enable_dependency_loading,
|
||||
: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
|
||||
|
||||
|
@ -57,6 +57,7 @@ module Rails
|
|||
@read_encrypted_secrets = false
|
||||
@content_security_policy = nil
|
||||
@content_security_policy_report_only = false
|
||||
@content_security_policy_nonce_generator = nil
|
||||
@require_master_key = false
|
||||
@loaded_config_version = nil
|
||||
end
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<title><%= camelized %></title>
|
||||
<%%= csrf_meta_tags %>
|
||||
<%%= csp_meta_tag %>
|
||||
|
||||
<%- if options[:skip_javascript] -%>
|
||||
<%%= stylesheet_link_tag 'application', media: 'all' %>
|
||||
|
|
|
@ -10,12 +10,15 @@
|
|||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https, :unsafe_inline
|
||||
# policy.style_src :self, :https
|
||||
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# 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
|
||||
# For further information see the following documentation:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
|
||||
|
|
Loading…
Reference in a new issue