提交 b2f0a894 编写于 作者: A Andrew White 提交者: Andrew White

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

Add support for automatic nonce generation for Rails UJS
上级 da8d0c94
* 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
......
#= 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/)
......
# 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
......
# 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
......
......@@ -17,48 +17,49 @@ class Configuration < ::Rails::Engine::Configuration
: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
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册