From c8f74a0b0e1e93146c4d7c1d6ac80c9079682216 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Mon, 19 Oct 2026 08:29:33 -0400 Subject: [PATCH] Compute a page's script sources once per importmap and host Every page parsed the importmap and joined each of its URLs against the request's base URL, about a millisecond of CPU a page, though the answer changes only with the importmap and the host. Keep the last answer, keyed by both. One entry, so a request with a made-up Host header can't grow it. Co-Authored-By: Claude Opus 5.5 --- .../concerns/inline_script_hashes.rb | 21 ++++++++++++++++++- .../content_security_policy_test.rb | 10 +++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/inline_script_hashes.rb b/app/controllers/concerns/inline_script_hashes.rb index 4577292..d2b6367 100644 --- a/app/controllers/concerns/inline_script_hashes.rb +++ b/app/controllers/concerns/inline_script_hashes.rb @@ -14,13 +14,32 @@ module InlineScriptHashes content_security_policy if: -> { request.content_security_policy } do |policy| if sources = policy.directives["script-src"] importmap = Rails.application.importmap.to_json(resolver: helpers) - policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT) + policy.directives["script-src"] = (sources - [ "'self'" ]) | script_sources(importmap) policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}" end end end + mattr_accessor :last_script_sources + private + # Parsing the importmap and resolving each of its URLs costs about a + # millisecond a page, yet the answer changes only with the importmap and + # the host the page is served from. Keep the last one: a single entry, so + # a request with a made-up Host header can't grow it. + def script_sources(importmap) + key = [ importmap, request.base_url, request.host_with_port, config.relative_url_root ] + cached_key, sources = InlineScriptHashes.last_script_sources + + if cached_key == key + sources + else + (script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)).freeze.tap do |sources| + InlineScriptHashes.last_script_sources = [ key, sources ].freeze + end + end + end + # The importmap is resolved by Rails' asset helpers, so its URLs already # carry any relative root and asset host, however the host is written: # with or without a scheme, protocol-relative, a %d set, or a proc. Each diff --git a/test/integration/content_security_policy_test.rb b/test/integration/content_security_policy_test.rb index 7a3aaf6..bd75e38 100644 --- a/test/integration/content_security_policy_test.rb +++ b/test/integration/content_security_policy_test.rb @@ -103,6 +103,16 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1] end + test "each host's pages carry that host's sources, one after another" do + [ "www.example.com", "www.example.com:8443", "www.example.com" ].each do |host| + host! host + get PAGE_PATH + follow_redirect! while response.redirect? + + assert_includes response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split, "#{host}/assets/" + end + end + test "uploads can't load as scripts" do get PAGE_PATH follow_redirect! while response.redirect?