mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-18 20:26:30 +09:00
Enforce a script policy that allows the importmap by hash
script-src 'self', object-src 'none' and base-uri 'self', enforced. The two inline scripts the importmap helper emits are allowed by their SHA-256, and the origins its URLs point to (a configured asset host, in any form Rails accepts) are allowed by name. There is no per-request nonce, so cached pages stay valid. DISABLE_CONTENT_SECURITY_POLICY=true leaves the policy off, for an install whose proxy or customization injects scripts.
This commit is contained in:
1 parent
cad2d7211d
commit
562888251e
5 files changed
+140
-24
No files matched your search
@@ -1,4 +1,4 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses, InlineScriptHashes
|
||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# Allows the scripts the importmap helper emits: the inline importmap and its
|
||||
# entry-point module by content hash, and the origins its URLs point to. The
|
||||
# hashes change only when the importmap does (a deploy), so cached HTML stays
|
||||
# valid, unlike a per-request nonce.
|
||||
module InlineScriptHashes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ENTRY_POINT_SCRIPT = %(import "application")
|
||||
|
||||
included do
|
||||
content_security_policy do |policy|
|
||||
if sources = policy.directives["script-src"]
|
||||
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
||||
policy.directives["script-src"] = sources | script_origins(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# The importmap is resolved by Rails' asset helpers, so its URLs already
|
||||
# carry any configured asset host, however it's written: with or without a
|
||||
# scheme, protocol-relative, a %d set, or a proc.
|
||||
def script_origins(importmap)
|
||||
JSON.parse(importmap)["imports"].values.filter_map do |url|
|
||||
origin = URI.join(request.base_url, url).origin
|
||||
origin unless origin == request.base_url
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def script_hashes(*scripts)
|
||||
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
||||
end
|
||||
end
|
||||
@@ -1,25 +1,18 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# Define an application-wide content security policy.
|
||||
# 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
|
||||
# Enforcing baseline policy. Scripts run only from this origin; the two inline
|
||||
# scripts the importmap helper emits are allowed by hash (InlineScriptHashes),
|
||||
# so there is no per-request nonce and rendered HTML stays cacheable.
|
||||
#
|
||||
# # Generate session nonces for permitted importmap and inline scripts
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src)
|
||||
# Nothing else is restricted: images, media, fonts, styles (including an
|
||||
# account's custom styles) and connections behave as before.
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose
|
||||
# proxy or customization injects scripts the policy would block.
|
||||
unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true"
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
policy.script_src :self
|
||||
policy.object_src :none
|
||||
policy.base_uri :self
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,18 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
ActionController::Base.perform_caching = @previous_caching
|
||||
end
|
||||
|
||||
test "a reused page carries the same enforcing policy, with no nonce" do
|
||||
get room_messages_url(@room)
|
||||
first = response.headers["Content-Security-Policy"]
|
||||
assert_match "script-src 'self' 'sha256-", first
|
||||
assert_no_match "nonce-", first
|
||||
|
||||
MessagesController.any_instance.expects(:find_paged_messages).never
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
assert_equal first, response.headers["Content-Security-Policy"]
|
||||
end
|
||||
|
||||
test "all four read actions reuse completed pages" do
|
||||
urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ]
|
||||
urls.each do |url|
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
require "test_helper"
|
||||
|
||||
class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
|
||||
PAGE_PATH = "/"
|
||||
|
||||
setup { sign_in :david }
|
||||
|
||||
test "enforcing policy allows exactly the inline scripts the page carries" do
|
||||
get root_url
|
||||
follow_redirect! while response.redirect?
|
||||
|
||||
policy = response.headers["Content-Security-Policy"]
|
||||
assert policy, "expected an enforcing Content-Security-Policy header"
|
||||
assert_nil response.headers["Content-Security-Policy-Report-Only"]
|
||||
assert_match "object-src 'none'", policy
|
||||
assert_match "base-uri 'self'", policy
|
||||
assert_no_match "unsafe-inline", policy
|
||||
assert_no_match "nonce-", policy
|
||||
|
||||
script_src = policy[/script-src ([^;]+)/, 1].split
|
||||
inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" }
|
||||
assert_operator inline.size, :>=, 2
|
||||
inline.each do |script|
|
||||
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(script.text)}'", "inline #{script["type"]} script not allowed by hash"
|
||||
end
|
||||
end
|
||||
|
||||
test "scripts may load from a configured asset host, however it's written" do
|
||||
{
|
||||
"https://cdn.example.test" => %r{\Ahttps://cdn\.example\.test\z},
|
||||
"cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
|
||||
"//cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z},
|
||||
"https://assets%d.example.test" => %r{\Ahttps://assets[0-3]\.example\.test\z},
|
||||
->(source) { "https://one.example.test" } => %r{\Ahttps://one\.example\.test\z},
|
||||
->(source, request) { "https://#{request.host}.example.test" } => %r{\Ahttps://www\.example\.com\.example\.test\z}
|
||||
}.each do |asset_host, expected_origin|
|
||||
with_asset_host(asset_host) do
|
||||
get PAGE_PATH
|
||||
follow_redirect! while response.redirect?
|
||||
assert_response :success
|
||||
|
||||
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||
page = Nokogiri::HTML5(response.body)
|
||||
urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values +
|
||||
page.css("link[rel=modulepreload]").map { it["href"] } + page.css("script[src]").map { it["src"] }
|
||||
origins = urls.map { URI.join(request.base_url, it).origin }.uniq - [ request.base_url ]
|
||||
|
||||
assert_not_empty origins, asset_host.inspect
|
||||
origins.each do |origin|
|
||||
assert_match expected_origin, origin, asset_host.inspect
|
||||
assert_includes script_src, origin, asset_host.inspect
|
||||
end
|
||||
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "the escape hatch boots without a policy" do
|
||||
output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" },
|
||||
[ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read)
|
||||
|
||||
assert_equal "nil", output
|
||||
end
|
||||
|
||||
test "uploaded files are guarded on their way out" do
|
||||
assert_includes Rails.application.middleware.map(&:klass), UploadedScriptGuard
|
||||
end
|
||||
|
||||
private
|
||||
def with_asset_host(asset_host)
|
||||
previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host
|
||||
Rails.application.importmap.send(:clear_cache)
|
||||
yield
|
||||
ensure
|
||||
ApplicationController.asset_host = previous
|
||||
Rails.application.importmap.send(:clear_cache)
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user