From 465b372935eba0bdd91c881efba8191aa747ea2f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 19:37:53 -0700 Subject: [PATCH] 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 559166b..81a4aca 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -53,7 +53,8 @@ module CSP # 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 @@ module CSP }.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 087c7b2..5bfd3b1 100644 --- a/test/integration/csp_nonce_test.rb +++ b/test/integration/csp_nonce_test.rb @@ -60,6 +60,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