diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9ad6c7c..b73e592 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/inline_script_hashes.rb b/app/controllers/concerns/inline_script_hashes.rb new file mode 100644 index 0000000..c0fde41 --- /dev/null +++ b/app/controllers/concerns/inline_script_hashes.rb @@ -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 diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 54f47cf..e343526 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -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 diff --git a/test/controllers/cached_responses_test.rb b/test/controllers/cached_responses_test.rb index 8968aa0..fa420f4 100644 --- a/test/controllers/cached_responses_test.rb +++ b/test/controllers/cached_responses_test.rb @@ -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| diff --git a/test/integration/content_security_policy_test.rb b/test/integration/content_security_policy_test.rb new file mode 100644 index 0000000..7e78d38 --- /dev/null +++ b/test/integration/content_security_policy_test.rb @@ -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