mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-27 16:06:48 +09:00
The nonce generator runs for every response that carries the policy, so the nonce_id cookie was set on QR codes, logos, avatars and disk-served blobs — all public, shared-cacheable. Those endpoints now opt out of the policy, so no cache can replay one client's identifier and nonce.
121 lines
5.3 KiB
Ruby
121 lines
5.3 KiB
Ruby
# Be sure to restart your server when you modify this file.
|
|
|
|
# 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
|
|
|
|
# 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.
|
|
#
|
|
# Campfire 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.
|
|
#
|
|
# The generator runs for every response that carries the policy, so the cookie
|
|
# write would otherwise land on public, shared-cacheable responses (QR codes,
|
|
# logos, avatars, disk-served blobs) where a cache could replay one client's
|
|
# identifier and nonce to others. Those endpoints opt out of the policy with
|
|
# `content_security_policy false`; the nonce is only meaningful on documents.
|
|
module CSP
|
|
module Nonce
|
|
COOKIE = "campfire_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
|
|
|
|
# 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-, semicolon-, 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.
|
|
#
|
|
# 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?)
|
|
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, *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, *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, *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 ]
|
|
|
|
# Report violations without enforcing the policy.
|
|
config.content_security_policy_report_only = true
|
|
end
|