Move CSP to nonces; remove unsafe-inline from script-src and style-src

unsafe-inline on script-src neutered CSP as an XSS defense on the login and OAuth
consent pages (the highest-value targets in an IdP). Switch to a per-response
nonce for both script-src and style-src and drop unsafe-inline entirely.

- Add a random per-response nonce generator and apply it to script-src/style-src.
- Remove :unsafe_inline from both directives.
- Nonce the one hand-written inline script (dark-mode detection in the layout).
- Convert the 2 static style="display:none" attributes to class="hidden" (their
  runtime toggle is done via element.style in JS, which CSP does not govern).

importmap-rails (2.2.3) already stamps the nonce onto its generated inline
importmap/module/preload tags, and Turbo (2.0.23) reads csp_meta_tag for its
injected <style>, so no other view changes were needed. Adds an integration test
asserting the enforcing header carries nonces, omits unsafe-inline, and that the
inline script's nonce matches the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 20:42:28 +10:00
parent 44892e3301
commit d49e7ce4f5
5 changed files with 58 additions and 9 deletions

View File

@@ -9,7 +9,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<script>
<script nonce="<%= content_security_policy_nonce %>">
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View File

@@ -38,7 +38,7 @@
</svg>
Continue with Passkey
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div>
<!-- Password section - shown by default, hidden if WebAuthn is required -->

View File

@@ -54,7 +54,7 @@
</svg>
Use Passkey Instead
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div>
</div>
<% end %>

View File

@@ -9,13 +9,15 @@ Rails.application.configure do
# Default to self for everything, plus blob: for file downloads
policy.default_src :self, "blob:"
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
policy.script_src :self, :unsafe_inline, "blob:"
# Scripts: self + per-response nonce (see nonce config below) + blob: for
# downloads. No unsafe-inline — importmap/Turbo/Stimulus inline tags carry the
# nonce automatically, and the one hand-written inline script is nonced.
policy.script_src :self, "blob:"
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
# and Stimulus controller style manipulations
policy.style_src :self, :unsafe_inline
# Styles: self + per-response nonce. No unsafe-inline Tailwind ships as an
# external stylesheet, Turbo's injected <style> carries the nonce, and Stimulus
# sets styles via the CSSOM (not governed by CSP).
policy.style_src :self
# Images: Allow self, data URLs, and https for external images
policy.img_src :self, :data, :https
@@ -59,6 +61,13 @@ Rails.application.configure do
policy.report_uri "/api/csp-violation-report"
end
# Per-response random nonce applied to script-src and style-src. The app does
# not page-cache HTML, so a fresh random nonce per response is the most secure
# choice (no reuse across responses). csp_meta_tag (in the layout) and
# importmap-rails read this nonce automatically.
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?

View File

@@ -0,0 +1,40 @@
require "test_helper"
class CspTest < ActionDispatch::IntegrationTest
# In the test env content_security_policy_report_only is false, so the enforcing
# Content-Security-Policy header is emitted.
test "signin page sends a nonce-based CSP with no unsafe-inline" do
get signin_path
assert_response :success
csp = response.headers["Content-Security-Policy"]
assert csp.present?, "expected a Content-Security-Policy header"
script_src = directive(csp, "script-src")
style_src = directive(csp, "style-src")
assert_includes script_src, "'nonce-", "script-src must carry a nonce"
assert_includes style_src, "'nonce-", "style-src must carry a nonce"
refute_includes script_src, "'unsafe-inline'", "script-src must not allow unsafe-inline"
refute_includes style_src, "'unsafe-inline'", "style-src must not allow unsafe-inline"
end
test "the inline theme script carries the script-src nonce" do
get signin_path
assert_response :success
header_nonce = response.headers["Content-Security-Policy"][/script-src[^;]*'nonce-([^']+)'/, 1]
assert header_nonce.present?, "expected a nonce in the CSP header"
# The hand-written dark-mode <script> in the layout must use the same nonce,
# otherwise it would be blocked under the enforcing policy.
assert_match(/<script nonce="#{Regexp.escape(header_nonce)}">/, response.body,
"inline theme script must carry the matching CSP nonce")
end
private
def directive(csp, name)
csp.split(";").map(&:strip).find { |d| d.start_with?("#{name} ") } || ""
end
end