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) ##
* 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*

View file

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

View file

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

View file

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

View file

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

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

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 -%>
# 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

View file

@ -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)
})
})()

View file

@ -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("<", "&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>

View file

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

View file

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

View file

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

View file

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

View file

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