mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Leave the scheme off this origin's script sources
Behind a proxy that terminates TLS without X-Forwarded-Proto, Rails sees
http while the page is https, and an http source on a non-default port
doesn't match. A source without a scheme takes the page's own.
(cherry picked from commit 0a494a10cb)
This commit is contained in:
1 parent
681bd78ea2
commit
26b3d2abb0
2 files changed
+25
-8
No files matched your search
@@ -15,7 +15,7 @@ module InlineScriptHashes
|
|||||||
if sources = policy.directives["script-src"]
|
if sources = policy.directives["script-src"]
|
||||||
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
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_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
|
end
|
||||||
end
|
end
|
||||||
@@ -26,6 +26,9 @@ module InlineScriptHashes
|
|||||||
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
|
# 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.
|
# 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
|
# A path only limits the first request: browsers check just the origin
|
||||||
# after a redirect. Nothing under the asset directory redirects.
|
# after a redirect. Nothing under the asset directory redirects.
|
||||||
def script_locations(importmap)
|
def script_locations(importmap)
|
||||||
@@ -33,8 +36,9 @@ module InlineScriptHashes
|
|||||||
|
|
||||||
JSON.parse(importmap)["imports"].values.map do |url|
|
JSON.parse(importmap)["imports"].values.map do |url|
|
||||||
url = URI.join(request.base_url, 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)
|
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.uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
|
|||||||
assert_match "base-uri 'self'", policy
|
assert_match "base-uri 'self'", policy
|
||||||
assert_no_match "unsafe-inline", policy
|
assert_no_match "unsafe-inline", policy
|
||||||
assert_no_match "nonce-", 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
|
script_src = policy[/script-src ([^;]+)/, 1].split
|
||||||
assert_not_includes script_src, "'self'"
|
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" }
|
inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] || s["type"] == "text/template" }
|
||||||
assert_operator inline.size, :>=, 2
|
assert_operator inline.size, :>=, 2
|
||||||
inline.each do |script|
|
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"] }
|
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 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 }
|
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }
|
||||||
end
|
end
|
||||||
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
|
test "uploads can't load as scripts" do
|
||||||
get PAGE_PATH
|
get PAGE_PATH
|
||||||
follow_redirect! while response.redirect?
|
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
|
# 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)
|
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}" }
|
urls.each { assert allowed_by_path?(script_src, it), "#{it} not allowed by #{script_src.inspect} #{message}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
# CSP source matching on a first request: 'self' and a bare origin match any
|
# 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)
|
def allowed_by_path?(script_src, url)
|
||||||
url = URI(url)
|
url = URI(url)
|
||||||
script_src.any? do |source|
|
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.is_a?(URI) && source.origin == url.origin &&
|
||||||
(source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path))
|
(source.path.empty? || (source.path.end_with?("/") ? url.path.start_with?(source.path) : url.path == source.path))
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user