Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 107 additions & 18 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,114 @@
# Be sure to restart your server when you modify this file.

# Define an application-wide content security policy.
# 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

# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
# Session-stable nonce for permitted inline scripts (the importmap JSON + shim,
# auto-nonced by importmap-rails).
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
# 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.
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
# Writebook 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.
module CSP
module Nonce
COOKIE = "writebook_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.
#
# Writebook 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 and the per-user
# hide_from_user_style_tag 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
103 changes: 103 additions & 0 deletions test/integration/csp_nonce_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
require "test_helper"

class CspNonceTest < ActionDispatch::IntegrationTest
test "policy is delivered Report-Only, not enforced" do
sign_in :david
get root_path

assert_response :success
assert response.headers["Content-Security-Policy-Report-Only"].present?,
"Expected a Report-Only CSP header"
assert_nil response.headers["Content-Security-Policy"],
"Policy must not be enforced yet"
end

test "nonce is stable across requests so Turbo restores don't trip CSP" do
sign_in :david

get root_path
nonce1 = report_only_nonce

get root_path
nonce2 = report_only_nonce

assert nonce1.present?, "Expected a nonce in the Report-Only CSP header"
assert_equal nonce1, nonce2, "Nonce must be stable across requests"
end

test "client-set identifier still yields an unpredictable HMAC nonce" do
fake_id = "attacker-controlled-value"
cookies[CSP::Nonce::COOKIE] = fake_id

get root_path
nonce = report_only_nonce

assert_equal CSP::Nonce.hmac(fake_id), nonce,
"Nonce must be HMAC-SHA256 of the identifier keyed by secret_key_base"
end

test "importmap script tag carries the nonce" do
sign_in :david
get root_path

assert_response :success
nonce = report_only_nonce
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 "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

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
end
Loading