diff --git a/app/controllers/concerns/inline_script_hashes.rb b/app/controllers/concerns/inline_script_hashes.rb index efc7fde..357a70d 100644 --- a/app/controllers/concerns/inline_script_hashes.rb +++ b/app/controllers/concerns/inline_script_hashes.rb @@ -15,7 +15,7 @@ module InlineScriptHashes 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.worker_src service_worker_url(format: :js) + policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}" end end end @@ -26,6 +26,9 @@ module InlineScriptHashes # with or without a scheme, protocol-relative, a %d set, or a proc. Each # is allowed up to its asset directory; one outside it is allowed exactly. # + # This origin's own sources leave out the scheme, so they match the page's + # scheme even behind a proxy that terminates TLS without saying so. + # # A path only limits the first request: browsers check just the origin # after a redirect. Nothing under the asset directory redirects. def script_locations(importmap) @@ -33,8 +36,9 @@ module InlineScriptHashes JSON.parse(importmap)["imports"].values.map do |url| url = URI.join(request.base_url, url) + origin = url.origin == request.base_url ? request.host_with_port : url.origin directory = url.path[0, url.path.index(assets) + assets.length] if url.path.include?(assets) - "#{url.origin}#{directory || url.path}" + "#{origin}#{directory || url.path}" end.uniq end diff --git a/test/controllers/cached_responses_test.rb b/test/controllers/cached_responses_test.rb index 26efc07..888994d 100644 --- a/test/controllers/cached_responses_test.rb +++ b/test/controllers/cached_responses_test.rb @@ -29,7 +29,7 @@ class CachedResponsesTest < ActionDispatch::IntegrationTest 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 %r{script-src http://[^/ ]+/assets/ 'sha256-}, first + assert_match %r{script-src [^/ ]+/assets/ 'sha256-}, first assert_no_match "nonce-", first MessagesController.any_instance.expects(:find_paged_messages).never diff --git a/test/integration/content_security_policy_test.rb b/test/integration/content_security_policy_test.rb index 67cf66c..b4672e0 100644 --- a/test/integration/content_security_policy_test.rb +++ b/test/integration/content_security_policy_test.rb @@ -16,11 +16,11 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest assert_match "base-uri 'self'", policy assert_no_match "unsafe-inline", policy assert_no_match "nonce-", policy - assert_equal "http://www.example.com/service-worker.js", policy[/worker-src ([^;]+)/, 1] + assert_equal "www.example.com/service-worker.js", policy[/worker-src ([^;]+)/, 1] script_src = policy[/script-src ([^;]+)/, 1].split assert_not_includes script_src, "'self'" - assert_includes script_src, "http://www.example.com/assets/" + assert_includes script_src, "www.example.com/assets/" inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" } assert_operator inline.size, :>=, 2 inline.each do |script| @@ -67,11 +67,21 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values + page.css("link[rel=modulepreload]").map { it["href"] } assert urls.all? { it.start_with?("/chat/assets/") }, urls.inspect - assert_includes script_src, "http://www.example.com/chat/assets/" + assert_includes script_src, "www.example.com/chat/assets/" assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s } end end + test "this origin's sources keep a non-default port and take the page's scheme" do + host! "www.example.com:8443" + get PAGE_PATH + follow_redirect! while response.redirect? + + policy = response.headers["Content-Security-Policy"] + assert_includes policy[/script-src ([^;]+)/, 1].split, "www.example.com:8443/assets/" + assert_equal "www.example.com:8443/service-worker.js", policy[/worker-src ([^;]+)/, 1] + end + test "uploads can't load as scripts" do get PAGE_PATH follow_redirect! while response.redirect? @@ -118,16 +128,19 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest # Every page script is allowed by a source with a path, never by a bare origin def assert_scripts_allowed_by_path(script_src, urls, message = nil) - assert_empty script_src.grep(%r{\A[a-z]+://[^/]+/?\z}), message + assert_empty script_src.grep(%r{\A([a-z]+://)?[^/']+/?\z}), message urls.each { assert allowed_by_path?(script_src, it), "#{it} not allowed by #{script_src.inspect} #{message}" } end # CSP source matching on a first request: 'self' and a bare origin match any - # path, a path ending in / matches as a prefix, any other path exactly + # path, a path ending in / matches as a prefix, any other path exactly. A + # source without a scheme takes the page's. def allowed_by_path?(script_src, url) url = URI(url) script_src.any? do |source| - source = URI(source == "'self'" ? request.base_url : source) if source == "'self'" || source.match?(%r{\A[a-z]+://}) + unless source.start_with?("'") && source != "'self'" + source = URI(source == "'self'" ? request.base_url : (source.include?("://") ? source : "#{request.scheme}://#{source}")) + end source.is_a?(URI) && source.origin == url.origin && (source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path)) end