From 8f3ec578ee762d4a7d1e4d639f5f468ecd7c0a70 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 14:26:37 -0700 Subject: [PATCH 1/4] Add a Report-Only Content-Security-Policy baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writebook ships no CSP today (the initializer is the stock, fully-commented file). This adds a conservative Report-Only policy — default-src 'self'; object-src 'none'; base-uri/frame-ancestors/form-action 'self' — so browsers evaluate and report violations without enforcing anything. Rendering and behavior are unaffected. Next steps: wire a report endpoint, tune against observed violations, then flip content_security_policy_report_only off to enforce. --- .../initializers/content_security_policy.rb | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b38..7453ea73 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,25 +1,25 @@ # 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 -# -# # 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) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.object_src :none + policy.base_uri :self + policy.frame_ancestors :self + policy.form_action :self + # Specify URI for violation reports once a report sink is available + # policy.report_uri "/csp-violation-report-endpoint" + end + + # Report violations without enforcing the policy. + config.content_security_policy_report_only = true +end From 9ea7017937768172844e4f7e936dc337a54c2ad3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 17:30:50 -0700 Subject: [PATCH 2/4] 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 either app renders. script-src gains the nonce; style-src keeps unsafe_inline for now (inline style= attributes and hide_from_user_style_tag 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 | 52 +++++++++++++++++++ 2 files changed, 102 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 7453ea73..9538f836 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. +# +# 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 +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 and the per-user + # hide_from_user_style_tag 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 00000000..485d3f86 --- /dev/null +++ b/test/integration/csp_nonce_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class CspNonceTest < ActionDispatch::IntegrationTest + test "policy is delivered Report-Only, not enforced" do + sign_in :david + get root_url + + 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 + + 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 From fe1e661f16339e075ac16ab4c8842d37e35a2dda Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 18:33:50 -0700 Subject: [PATCH 3/4] Make CSP embed directives configurable per install Writebook is self-hosted per customer: 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. Hardcoding these directives at :self would break those integrations once the policy is enforced. Read per-install extras from ENV (CSP_EXTRA_SCRIPT_SRC, _STYLE_SRC, _IMG_SRC, _CONNECT_SRC, _FRAME_SRC, _FORM_ACTION), each a comma- or whitespace-separated host list, appended to the :self baseline. Unset by default, so the policy stays at :self only unless an admin opts in. Also switch the integration test to _path helpers per AGENTS.md. --- .../initializers/content_security_policy.rb | 54 +++++++++++++++---- test/integration/csp_nonce_test.rb | 40 ++++++++++++-- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 9538f836..cedefbe4 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -43,27 +43,59 @@ def self.set_nonce_id(request) value end end -end -Rails.application.configure do - config.content_security_policy do |policy| + # 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- 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. + 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 # nonce auto-appended via nonce_directives below + 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 - # 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.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 + 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 ] diff --git a/test/integration/csp_nonce_test.rb b/test/integration/csp_nonce_test.rb index 485d3f86..125e1eae 100644 --- a/test/integration/csp_nonce_test.rb +++ b/test/integration/csp_nonce_test.rb @@ -3,7 +3,7 @@ class CspNonceTest < ActionDispatch::IntegrationTest test "policy is delivered Report-Only, not enforced" do sign_in :david - get root_url + get root_path assert_response :success assert response.headers["Content-Security-Policy-Report-Only"].present?, @@ -15,10 +15,10 @@ class CspNonceTest < ActionDispatch::IntegrationTest test "nonce is stable across requests so Turbo restores don't trip CSP" do sign_in :david - get root_url + get root_path nonce1 = report_only_nonce - get root_url + get root_path nonce2 = report_only_nonce assert nonce1.present?, "Expected a nonce in the Report-Only CSP header" @@ -29,7 +29,7 @@ class CspNonceTest < ActionDispatch::IntegrationTest fake_id = "attacker-controlled-value" cookies[CSP::Nonce::COOKIE] = fake_id - get root_url + get root_path nonce = report_only_nonce assert_equal CSP::Nonce.hmac(fake_id), nonce, @@ -38,14 +38,44 @@ class CspNonceTest < ActionDispatch::IntegrationTest test "importmap script tag carries the nonce" do sign_in :david - get root_url + 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 "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 From c8e13ba0433af7341b340826211d7c291163bea8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 19:37:51 -0700 Subject: [PATCH 4/4] Tokenize CSP_EXTRA_* semicolons to avoid a per-request 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-request policy build (forced by the nonce) means a stray semicolon in a CSP_EXTRA_* value — a plausible operator paste like "https://youtube.com; https://vimeo.com" — lands inside a single source token and makes Rails raise InvalidDirectiveError on every request, a site-wide 500 even in report-only mode. Split on ';' alongside comma and whitespace so such a value tokenizes into valid sources. Rails still validates each token, so no injection is introduced (a token with an embedded space still fails validation). --- .../initializers/content_security_policy.rb | 13 ++++++++++-- test/integration/csp_nonce_test.rb | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index cedefbe4..d85e080b 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -53,7 +53,8 @@ def self.set_nonce_id(request) # 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- or whitespace-separated list of CSP source expressions, e.g. + # 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" # @@ -68,8 +69,16 @@ def self.set_nonce_id(request) }.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?) + 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 diff --git a/test/integration/csp_nonce_test.rb b/test/integration/csp_nonce_test.rb index 125e1eae..2edb3626 100644 --- a/test/integration/csp_nonce_test.rb +++ b/test/integration/csp_nonce_test.rb @@ -58,6 +58,27 @@ class CspNonceTest < ActionDispatch::IntegrationTest 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