diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b38..d85e080b 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,25 +1,114 @@ # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy. +# Baseline application-wide Content-Security-Policy, deployed in Report-Only +# mode: browsers evaluate the policy and report violations (once a report +# endpoint is wired up) but enforce nothing, so rendering cannot break. +# Tune the policy against observed violations, then flip +# `content_security_policy_report_only` off to enforce it. +# # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.configure do -# 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 -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end +# Session-stable nonce for permitted inline scripts (the importmap JSON + shim, +# auto-nonced by importmap-rails). # -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) +# 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. # -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +# Writebook 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 = "writebook_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 + + # Per-install CSP extras. + # + # Writebook is a ONCE product: each customer self-hosts it on their own domain + # and an admin may embed or connect to external hosts — video/embed providers, + # image CDNs, analytics, form or webhook endpoints — that vary per install and + # are unknown at build time. `:self` already tracks this install's own origin; + # these ENV knobs let an admin allow additional hosts without editing this file + # (and without which enforcement would break their legitimate integrations). + # + # Each is a comma-, semicolon-, or whitespace-separated list of CSP source + # expressions, e.g. + # + # CSP_EXTRA_FRAME_SRC="https://www.youtube.com https://player.vimeo.com" + # + # Leave them unset (the default) to keep each directive at :self only. + EXTRA_ENV = { + script_src: "CSP_EXTRA_SCRIPT_SRC", + style_src: "CSP_EXTRA_STYLE_SRC", + img_src: "CSP_EXTRA_IMG_SRC", + connect_src: "CSP_EXTRA_CONNECT_SRC", + frame_src: "CSP_EXTRA_FRAME_SRC", + form_action: "CSP_EXTRA_FORM_ACTION" + }.freeze + + # Parse one ENV knob into a list of extra host sources. + # + # Semicolons are tokenized like commas/whitespace: because the nonce forces a + # per-request policy build, a stray `;` in a value (a plausible operator paste, + # e.g. "https://youtube.com; https://vimeo.com") would otherwise land inside a + # single source token and make Rails raise InvalidDirectiveError on every + # request — a site-wide 500 even in report-only mode. Splitting on `;` yields + # valid tokens instead; Rails still validates each one, so no injection is + # introduced (a token with an embedded space still fails validation). + def self.extra(directive) + ENV[EXTRA_ENV.fetch(directive)].to_s.split(/[,;\s]+/).reject(&:blank?) + end + + # Build the baseline policy. Kept as a reusable method so it can be exercised + # in isolation by tests as well as at boot. + def self.apply(policy) + policy.default_src :self + policy.script_src :self, *extra(:script_src) # nonce auto-appended via nonce_directives below + # unsafe_inline retained: many style="…" attributes and the per-user + # hide_from_user_style_tag can't be nonced yet. + policy.style_src :self, :unsafe_inline, *extra(:style_src) + # frame_src / img_src / connect_src start at :self plus any per-install extras + # and get tuned against violation reports during the report-only window. + policy.img_src :self, :data, :blob, *extra(:img_src) + policy.connect_src :self, *extra(:connect_src) + policy.frame_src :self, *extra(:frame_src) + policy.frame_ancestors :self + policy.base_uri :self + policy.form_action :self, *extra(:form_action) + policy.object_src :none + # Specify URI for violation reports once a report sink is available + # policy.report_uri "/csp-violation-report-endpoint" + end +end + +Rails.application.configure do + config.content_security_policy { |policy| CSP.apply(policy) } + + 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 00000000..2edb3626 --- /dev/null +++ b/test/integration/csp_nonce_test.rb @@ -0,0 +1,103 @@ +require "test_helper" + +class CspNonceTest < ActionDispatch::IntegrationTest + test "policy is delivered Report-Only, not enforced" do + sign_in :david + get root_path + + 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_path + nonce1 = report_only_nonce + + get root_path + 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_path + 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_path + + assert_response :success + nonce = report_only_nonce + assert_select "script[type='importmap'][nonce=?]", nonce + end + + test "a per-install ENV extra host is appended to its directive" do + with_env "CSP_EXTRA_FRAME_SRC" => "https://player.vimeo.com https://www.youtube.com", + "CSP_EXTRA_IMG_SRC" => "https://cdn.example.test" do + header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build + + assert_match %r{frame-src[^;]*\bhttps://player\.vimeo\.com\b}, header + assert_match %r{frame-src[^;]*\bhttps://www\.youtube\.com\b}, header + assert_match %r{img-src[^;]*\bhttps://cdn\.example\.test\b}, header + # :self is preserved alongside the extras. + assert_match %r{frame-src 'self'}, header + end + end + + test "a semicolon-separated ENV extra tokenizes into valid sources without raising" do + # A plausible operator paste separates hosts with "; ". Because the nonce + # forces a per-request policy build, a semicolon left inside a single source + # token would make Rails raise InvalidDirectiveError on every request — a + # site-wide 500 even in report-only mode. Splitting on ';' must yield both + # hosts as valid tokens and never raise. + with_env "CSP_EXTRA_FRAME_SRC" => "https://a.example; https://b.example" do + header = nil + assert_nothing_raised do + header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build + end + + assert_match %r{frame-src[^;]*\bhttps://a\.example\b}, header + assert_match %r{frame-src[^;]*\bhttps://b\.example\b}, header + # Both hosts share the one frame-src directive; the semicolon did not leak + # a second directive into the policy. + assert_equal 1, header.scan(/(?:^|;\s*)frame-src\b/).size, + "Expected exactly one frame-src directive" + end + end + + test "directives default to :self only when no ENV extras are set" do + with_env "CSP_EXTRA_FRAME_SRC" => nil, "CSP_EXTRA_IMG_SRC" => nil do + header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build + + assert_match %r{frame-src 'self'(;|\z)}, header + end + end + + private + def with_env(vars) + original = {} + vars.each_key { |k| original[k] = ENV.key?(k) ? ENV[k] : :__unset__ } + vars.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } + yield + ensure + original.each { |k, v| v == :__unset__ ? ENV.delete(k) : ENV[k] = v } + end + + def report_only_nonce + response.headers["Content-Security-Policy-Report-Only"].to_s[/'nonce-([^']+)'/, 1] + end +end