mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-18 20:26:30 +09:00
Allow scripts only from the asset directory, not the whole origin
Uploads are served from this origin too, so script-src 'self' would run one that a cache still holds under a script type from before the upload guard. Application pages now allow each importmap URL up to its asset directory (relative root and asset host included), the inline importmap by hash, and the service worker by worker-src.
This commit is contained in:
1 parent
2a449401ec
commit
2be2227f40
5 files changed
+101
-34
No files matched your search
@@ -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 "script-src 'self' 'sha256-", first
|
||||
assert_match %r{script-src http://[^/ ]+/assets/ 'sha256-}, first
|
||||
assert_no_match "nonce-", first
|
||||
|
||||
MessagesController.any_instance.expects(:find_paged_messages).never
|
||||
|
||||
@@ -16,8 +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]
|
||||
|
||||
script_src = policy[/script-src ([^;]+)/, 1].split
|
||||
assert_not_includes script_src, "'self'"
|
||||
assert_includes script_src, "http://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|
|
||||
@@ -46,15 +49,43 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
origins.each { assert_match expected_origin, it, asset_host.inspect }
|
||||
assert_scripts_allowed_by_path script_src, urls.map { URI.join(request.base_url, it).to_s }, asset_host.inspect
|
||||
assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "scripts are limited to the asset directory under a relative root" do
|
||||
with_relative_url_root("/chat") 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"] }
|
||||
|
||||
assert urls.all? { it.start_with?("/chat/assets/") }, urls.inspect
|
||||
assert_includes script_src, "http://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 "uploads can't load as scripts" do
|
||||
get PAGE_PATH
|
||||
follow_redirect! while response.redirect?
|
||||
|
||||
script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new("alert(1)"), filename: "upload.js", content_type: "text/javascript")
|
||||
|
||||
disk_url = ActiveStorage::Current.set(url_options: { protocol: "http", host: "www.example.com" }) { blob.url }
|
||||
|
||||
[ rails_blob_url(blob), rails_blob_url(blob, disposition: :inline), rails_storage_proxy_url(blob), disk_url ].each do |url|
|
||||
assert_not allowed_by_path?(script_src, url), "#{url} allowed by #{script_src.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
test "with the policy turned off, pages send none" do
|
||||
without_policy do
|
||||
get PAGE_PATH
|
||||
@@ -85,6 +116,32 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
|
||||
Rails.application.env_config["action_dispatch.content_security_policy"] = previous
|
||||
end
|
||||
|
||||
# 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
|
||||
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
|
||||
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]+://})
|
||||
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
|
||||
end
|
||||
|
||||
def with_relative_url_root(root)
|
||||
previous, ApplicationController.config.relative_url_root = ApplicationController.config.relative_url_root, root
|
||||
Rails.application.importmap.send(:clear_cache)
|
||||
yield
|
||||
ensure
|
||||
ApplicationController.config.relative_url_root = previous
|
||||
Rails.application.importmap.send(:clear_cache)
|
||||
end
|
||||
|
||||
def with_asset_host(asset_host)
|
||||
previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host
|
||||
Rails.application.importmap.send(:clear_cache)
|
||||
|
||||
Reference in new issue
Block a user