mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-17 14:02:09 +09:00
Add a session-stable CSP nonce and complete the directive set
Layer a Turbo-safe nonce and a full directive set onto the Report-Only baseline, still without enforcing anything. The nonce is the HMAC-SHA256 of a stable per-visitor cookie, keyed by secret_key_base. It stays constant across a session's requests so Turbo snapshot restores don't replay a stale nonce and trip the policy, while staying unpredictable to a client that can set the cookie but not the secret. importmap-rails auto-nonces the importmap JSON and shim, the only inline scripts the app renders (the message _template is a non-executed text/template data block). script-src gains the nonce; style-src keeps unsafe_inline for now (inline style= attributes across the composer and ActionText content aren't nonceable yet); img/connect/frame-src start at :self as tuning starting points for the report-only window. report_only stays true; enforcement is a later flip.
This commit is contained in:
@@ -9,17 +9,65 @@
|
||||
# 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.
|
||||
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
|
||||
end
|
||||
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
policy.default_src :self
|
||||
policy.object_src :none
|
||||
policy.base_uri :self
|
||||
policy.script_src :self # 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.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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require "test_helper"
|
||||
|
||||
class CspNonceTest < ActionDispatch::IntegrationTest
|
||||
test "policy is delivered Report-Only, not enforced" do
|
||||
sign_in :david
|
||||
get root_url
|
||||
follow_redirect! # root redirects to the last room
|
||||
|
||||
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
|
||||
follow_redirect! # root redirects to the last room
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user