Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
84 changes: 66 additions & 18 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,73 @@
# 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
end

Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self # 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
# 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.frame_ancestors :self
policy.base_uri :self
policy.form_action :self
policy.object_src :none
# Specify URI for violation reports once a report sink is available
# policy.report_uri "/csp-violation-report-endpoint"
end

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
52 changes: 52 additions & 0 deletions test/integration/csp_nonce_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require "test_helper"

class CspNonceTest < ActionDispatch::IntegrationTest
test "policy is delivered Report-Only, not enforced" do
sign_in :david
get root_url
Comment thread
jeremy marked this conversation as resolved.
Outdated

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_url
nonce1 = report_only_nonce

get root_url
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_url
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_url

assert_response :success
nonce = report_only_nonce
assert_select "script[type='importmap'][nonce=?]", nonce
end

private
def report_only_nonce
response.headers["Content-Security-Policy-Report-Only"].to_s[/'nonce-([^']+)'/, 1]
end
end
Loading