Below is a strict, but still workable Content Security Policy for your Ruby on Rails project. Use this CSP if you want to be very explicit about what scripts you allow, while keeping pragmatic defaults for styles, images, etc. This CSP does not use the viral strict-dynamic source (reasoning).
We also have a very compatible CSP which is more liberal. Compatibility might outweigh strictness if you have a lot of scripts you cannot control, e.g. third-party marketing scripts.
Initializer template
Replace your config/initializers/content_security_policy.rb with the code below. Go through each comment and make adjustments were necessarily.
Rails.application.config.content_security_policy do |policy|
# Allow nothing by default
policy.default_src :none
# Allow fetch and websocket requests to our own host.
# Also allow rack-livereload (if used), which runs on another port.
policy.connect_src :self, *(["ws://localhost:#{Rack::LiveReload::BodyProcessor::LIVERELOAD_PORT}/livereload"] if defined?(Rack::LiveReload))
# Allow web fonts from our own host only.
# If you use Google Fonts, add 'fonts.googleapis.com'.
policy.font_src :self
# Allow <iframe>s from our own host only.
# If you use external widgets that use iframes, you need to add their hosts,
# e.g. 'https://js.stripe.com' or 'https://www.recaptcha.net'
policy.frame_src :self
# Allow <img> elements loading from our own host only.
# Also allow "data:..." src attributes for icons, QR codes, canvas.toDataURL()
policy.img_src :self, :data
# Allow Web Application Manifests (for PWAs) from our own host only.
policy.manifest_src :self
# Only allow <script> elements with a valid [nonce] attribute.
# The nonce is added by the separate config.content_security_policy_nonce_directives below.
policy.script_src ''
# Allow <link rel="stylesheet"> from our own host only.
# We would love to use nonces here, but <link> does not support a [nonce] attribute.
# If you use libraries that inject [style] attributes or <style> elements,
# you might need to add :unsafe_inline here.
policy.style_src :self
# Allow forms to post to our own host only.
# This prevents attackers from changing the [action] attribute.
policy.form_action :self
end
# Add a nonce to script-src
Rails.application.config.content_security_policy_nonce_directives = %w[script-src]
# Avoid changing nonces for the same session to not break e-tags.
Rails.application.config.content_security_policy_nonce_generator = lambda { |request|
unless request.path == '/__better_errors'
# Force the session to load or session.id will be nil
request.session.send(:load_for_write!)
request.session.id.to_s.presence || SecureRandom.base64(16)
end
}
Then, find all occurences of javascript_include_tag, javascript_tag, javascript_pack_tag etc. and add a nonce: true option.
Also, make sure your views don't render <script> elements without javascript_tag.
Why avoid script-dynamic?
Our very compatible CSP uses strict-dynamic in a CSP like this:
Content-Security-Policy: 'nonce-secret123' 'strict-dynamic'
This allows all <script nonce="secret123"> elements. The permission is viral, so that script can insert other <script> elements without nonces. This let you use external tools that need to lazy-load additional scripts and that you cannot configure to use your nonce.
Using strict-dynamic is not necessarily insecure. It can be a little hard to reason about its implications though, especially on apps with a lot of JavaScript.
Lets say an attacker could inject an script into /page, e.g. by rendering WYSIWYG content without sanitization:
<script src="https://evil.com"></script>
During an initial page load to /page, your CSP will successfully prevent the script from running, as it has no valid [nonce] attribute.
But now assume your (allowed) application script (or any npm package you use) does something like this anywhere:
let response = await fetch('/page')
let html = await response.text()
document.querySelector('#content').innerHTML = html
Now the virality of script-dynamic allows the attacker script to run, bypassing the intent of your CSP.