From 99266973fdba51b0dcb8eff38da94436574abad0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 17:30:58 -0700 Subject: [PATCH] Add a session-stable CSP nonce and complete the directive set Layer a Turbo-safe nonce and a full directive set onto the Report-Only baseline, still without enforcing anything. The nonce is the HMAC-SHA256 of a stable per-visitor cookie, keyed by secret_key_base. It stays constant across a session's requests so Turbo snapshot restores don't replay a stale nonce and trip the policy, while staying unpredictable to a client that can set the cookie but not the secret. importmap-rails auto-nonces the importmap JSON and shim, the only inline scripts the app renders (the message _template is a non-executed text/template data block). script-src gains the nonce; style-src keeps unsafe_inline for now (inline style= attributes across the composer and ActionText content aren't nonceable yet); img/connect/frame-src start at :self as tuning starting points for the report-only window. report_only stays true; enforcement is a later flip. --- .../initializers/content_security_policy.rb | 52 +++++++++++++++++- test/integration/csp_nonce_test.rb | 54 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/integration/csp_nonce_test.rb diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 7453ea7..0e10de0 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -9,17 +9,65 @@ # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header +# Session-stable nonce for permitted inline scripts (the importmap JSON + shim, +# auto-nonced by importmap-rails). +# +# The nonce is stable across a session's requests so Turbo snapshot restores +# don't replay a stale nonce and trip CSP. It's the HMAC of a stable cookie +# value keyed by the server secret: the cookie is client-settable, but the +# client can't predict the resulting nonce without knowing secret_key_base. +# +# Campfire sets no per-session verification cookie, so the lightweight +# nonce_id cookie — set on first visit, present for every session including +# unauthenticated ones — is the sole identifier. +module CSP + module Nonce + COOKIE = "campfire_csp_nonce_id" + + def self.generate(request) + hmac(nonce_id(request)) + end + + def self.hmac(identifier) + OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, identifier) + end + + # Read or initialize a stable nonce identifier cookie. + def self.nonce_id(request) + request.cookies[COOKIE] || set_nonce_id(request) + end + + def self.set_nonce_id(request) + value = SecureRandom.base64(16) + request.cookie_jar[COOKIE] = { value: value, httponly: true, same_site: :lax } + value + end + end +end + Rails.application.configure do config.content_security_policy do |policy| policy.default_src :self - policy.object_src :none - policy.base_uri :self + policy.script_src :self # nonce auto-appended via nonce_directives below + # unsafe_inline retained: many style="…" attributes across the message + # composer and ActionText content can't be nonced yet. + policy.style_src :self, :unsafe_inline + # frame_src / img_src / connect_src start at :self and get tuned against + # violation reports during the report-only window. + policy.img_src :self, :data, :blob + policy.connect_src :self + policy.frame_src :self policy.frame_ancestors :self + policy.base_uri :self policy.form_action :self + policy.object_src :none # Specify URI for violation reports once a report sink is available # policy.report_uri "/csp-violation-report-endpoint" end + config.content_security_policy_nonce_generator = ->(request) { CSP::Nonce.generate(request) } + config.content_security_policy_nonce_directives = %w[ script-src ] + # Report violations without enforcing the policy. config.content_security_policy_report_only = true end diff --git a/test/integration/csp_nonce_test.rb b/test/integration/csp_nonce_test.rb new file mode 100644 index 0000000..019fe65 --- /dev/null +++ b/test/integration/csp_nonce_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class CspNonceTest < ActionDispatch::IntegrationTest + test "policy is delivered Report-Only, not enforced" do + sign_in :david + get root_url + follow_redirect! # root redirects to the last room + + assert_response :success + assert response.headers["Content-Security-Policy-Report-Only"].present?, + "Expected a Report-Only CSP header" + assert_nil response.headers["Content-Security-Policy"], + "Policy must not be enforced yet" + end + + test "nonce is stable across requests so Turbo restores don't trip CSP" do + sign_in :david + + get root_url + nonce1 = report_only_nonce + + get root_url + nonce2 = report_only_nonce + + assert nonce1.present?, "Expected a nonce in the Report-Only CSP header" + assert_equal nonce1, nonce2, "Nonce must be stable across requests" + end + + test "client-set identifier still yields an unpredictable HMAC nonce" do + fake_id = "attacker-controlled-value" + cookies[CSP::Nonce::COOKIE] = fake_id + + get root_url + nonce = report_only_nonce + + assert_equal CSP::Nonce.hmac(fake_id), nonce, + "Nonce must be HMAC-SHA256 of the identifier keyed by secret_key_base" + end + + test "importmap script tag carries the nonce" do + sign_in :david + get root_url + follow_redirect! # root redirects to the last room + + assert_response :success + nonce = report_only_nonce + assert_select "script[type='importmap'][nonce=?]", nonce + end + + private + def report_only_nonce + response.headers["Content-Security-Policy-Report-Only"].to_s[/'nonce-([^']+)'/, 1] + end +end