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.
This commit is contained in:
Jeremy Daer
2026-08-08 18:33:58 -07:00
parent 99266973fd
commit b11ed1cb6e
2 changed files with 73 additions and 11 deletions
+43 -11
View File
@@ -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 ]
+30
View File
@@ -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