mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
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.
This commit is contained in:
parent
dc6185b462
commit
31abee0341
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,48 +17,49 @@ 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
|
||||
|
||||
def initialize(*)
|
||||
super
|
||||
self.encoding = Encoding::UTF_8
|
||||
@allow_concurrency = nil
|
||||
@consider_all_requests_local = false
|
||||
@filter_parameters = []
|
||||
@filter_redirect = []
|
||||
@helpers_paths = []
|
||||
@public_file_server = ActiveSupport::OrderedOptions.new
|
||||
@public_file_server.enabled = true
|
||||
@public_file_server.index_name = "index"
|
||||
@force_ssl = false
|
||||
@ssl_options = {}
|
||||
@session_store = nil
|
||||
@time_zone = "UTC"
|
||||
@beginning_of_week = :monday
|
||||
@log_level = :debug
|
||||
@generators = app_generators
|
||||
@cache_store = [ :file_store, "#{root}/tmp/cache/" ]
|
||||
@railties_order = [:all]
|
||||
@relative_url_root = ENV["RAILS_RELATIVE_URL_ROOT"]
|
||||
@reload_classes_only_on_change = true
|
||||
@file_watcher = ActiveSupport::FileUpdateChecker
|
||||
@exceptions_app = nil
|
||||
@autoflush_log = true
|
||||
@log_formatter = ActiveSupport::Logger::SimpleFormatter.new
|
||||
@eager_load = nil
|
||||
@secret_token = nil
|
||||
@secret_key_base = nil
|
||||
@api_only = false
|
||||
@debug_exception_response_format = nil
|
||||
@x = Custom.new
|
||||
@enable_dependency_loading = false
|
||||
@read_encrypted_secrets = false
|
||||
@content_security_policy = nil
|
||||
@content_security_policy_report_only = false
|
||||
@require_master_key = false
|
||||
@loaded_config_version = nil
|
||||
self.encoding = Encoding::UTF_8
|
||||
@allow_concurrency = nil
|
||||
@consider_all_requests_local = false
|
||||
@filter_parameters = []
|
||||
@filter_redirect = []
|
||||
@helpers_paths = []
|
||||
@public_file_server = ActiveSupport::OrderedOptions.new
|
||||
@public_file_server.enabled = true
|
||||
@public_file_server.index_name = "index"
|
||||
@force_ssl = false
|
||||
@ssl_options = {}
|
||||
@session_store = nil
|
||||
@time_zone = "UTC"
|
||||
@beginning_of_week = :monday
|
||||
@log_level = :debug
|
||||
@generators = app_generators
|
||||
@cache_store = [ :file_store, "#{root}/tmp/cache/" ]
|
||||
@railties_order = [:all]
|
||||
@relative_url_root = ENV["RAILS_RELATIVE_URL_ROOT"]
|
||||
@reload_classes_only_on_change = true
|
||||
@file_watcher = ActiveSupport::FileUpdateChecker
|
||||
@exceptions_app = nil
|
||||
@autoflush_log = true
|
||||
@log_formatter = ActiveSupport::Logger::SimpleFormatter.new
|
||||
@eager_load = nil
|
||||
@secret_token = nil
|
||||
@secret_key_base = nil
|
||||
@api_only = false
|
||||
@debug_exception_response_format = nil
|
||||
@x = Custom.new
|
||||
@enable_dependency_loading = false
|
||||
@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
|
||||
|
||||
def load_defaults(target_version)
|
||||
|
|
|
@ -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