mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-26 23:46:49 +09:00
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 <noreply@anthropic.com>
70 lines
3.1 KiB
Ruby
70 lines
3.1 KiB
Ruby
# Allows the scripts the importmap helper emits and nothing else: the inline
|
|
# importmap and its entry-point module by content hash, and the asset
|
|
# directory its URLs point into. Not the whole origin, because uploads are
|
|
# served from this origin too. 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
|
|
# With the policy turned off there's nothing to add to, so leave the
|
|
# request without one rather than starting an empty policy
|
|
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_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
|
|
# is allowed up to its asset directory, the last one in its path, since an
|
|
# asset host's own path may contain one too; a URL 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)
|
|
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
|
|
|
|
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.rindex(assets) + assets.length] if url.path.include?(assets)
|
|
"#{origin}#{directory || url.path}"
|
|
end.uniq
|
|
end
|
|
|
|
def script_hashes(*scripts)
|
|
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
|
end
|
|
end
|