mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-08-28 17:42:50 +09:00
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:
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user