From b11ed1cb6e81dd492db300a4c2de9d77add90405 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 18:33:58 -0700 Subject: [PATCH] Make CSP embed directives configurable per install Campfire 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. --- .../initializers/content_security_policy.rb | 54 +++++++++++++++---- test/integration/csp_nonce_test.rb | 30 +++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 0e10de0..559166b 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -43,27 +43,59 @@ module CSP value end end -end -Rails.application.configure do - config.content_security_policy do |policy| + # Per-install CSP extras. + # + # Campfire 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 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.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 019fe65..087c7b2 100644 --- a/test/integration/csp_nonce_test.rb +++ b/test/integration/csp_nonce_test.rb @@ -47,7 +47,37 @@ class CspNonceTest < ActionDispatch::IntegrationTest 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