Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SRI support #199

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions app/helpers/importmap/importmap_tags_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ def javascript_importmap_tags(entry_point = "application", shim: true, importmap
safe_join [
javascript_inline_importmap_tag(importmap.to_json(resolver: self)),
javascript_importmap_module_preload_tags(importmap),
(javascript_importmap_shim_nonce_configuration_tag if shim),
(javascript_importmap_integrity_tags if shim),
(javascript_importmap_shim_configuration_tag if shim),
(javascript_importmap_shim_tag if shim),
javascript_import_module_tag(entry_point)
].compact, "\n"
Expand All @@ -14,14 +15,21 @@ def javascript_importmap_tags(entry_point = "application", shim: true, importmap
# By default, `Rails.application.importmap.to_json(resolver: self)` is used.
def javascript_inline_importmap_tag(importmap_json = Rails.application.importmap.to_json(resolver: self))
tag.script importmap_json.html_safe,
type: "importmap", "data-turbo-track": "reload", nonce: request&.content_security_policy_nonce
type: "importmap-shim", "data-turbo-track": "reload", nonce: request&.content_security_policy_nonce
end

# Configure es-modules-shim with nonce support if the application is using a content security policy.
def javascript_importmap_shim_nonce_configuration_tag
# Configure es-modules-shim with nonce support if the application is using a content security policy, and/or with integrity enforcement enabled, if any integrity shas are present.
def javascript_importmap_shim_configuration_tag
configuration = {}
tag_options = { type: "esms-options" }
if request&.content_security_policy
tag.script({ nonce: request.content_security_policy_nonce }.to_json.html_safe,
type: "esms-options", nonce: request.content_security_policy_nonce)
configuration[:nonce] = tag_options[:nonce] = request.content_security_policy_nonce
end
if Rails.application.importmap.integrities(resolver: self).any?
configuration[:enforceIntegrity] = true
end
if configuration.any?
tag.script(configuration.to_json.html_safe, **tag_options)
end
end

Expand All @@ -35,7 +43,7 @@ def javascript_importmap_shim_tag(minimized: true)
def javascript_import_module_tag(*module_names)
imports = Array(module_names).collect { |m| %(import "#{m}") }.join("\n")
tag.script imports.html_safe,
type: "module", nonce: request&.content_security_policy_nonce
type: "module-shim", nonce: request&.content_security_policy_nonce
end

# Link tags for preloading all modules marked as preload: true in the `importmap`
Expand All @@ -51,4 +59,10 @@ def javascript_module_preload_tag(*paths)
tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce
}, "\n")
end

def javascript_importmap_integrity_tags(importmap = Rails.application.importmap)
safe_join(Array(importmap.integrities(resolver: self)).collect { |m, integrity|
tag.link rel: "modulepreload-shim", href: m, integrity: integrity
}, "\n")
end
end
7 changes: 5 additions & 2 deletions lib/importmap/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ def self.exit_on_failure?
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
option :download, type: :boolean, aliases: :d, default: false
option :integrity, type: :boolean, aliases: :i, default: false
def pin(*packages)
if imports = packager.import(*packages, env: options[:env], from: options[:from])
imports.each do |package, url|
if options[:download]
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
packager.download(package, url)
pin = packager.vendored_pin_for(package, url)
integrity = packager.calculate_integrity(package: package) if options[:integrity]
pin = packager.vendored_pin_for(package, url, integrity: integrity)
else
puts %(Pinning "#{package}" to #{url})
pin = packager.pin_for(package, url)
integrity = packager.calculate_integrity(url: url) if options[:integrity]
pin = packager.pin_for(package, url, integrity: integrity)
end

if packager.packaged?(package)
Expand Down
18 changes: 15 additions & 3 deletions lib/importmap/map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def draw(path = nil, &block)
self
end

def pin(name, to: nil, preload: false)
def pin(name, to: nil, preload: false, integrity: false)
clear_cache
@packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
@packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
end

def pin_all_from(dir, under: nil, to: nil, preload: false)
Expand Down Expand Up @@ -83,9 +83,21 @@ def cache_sweeper(watches: nil)
end
end

def integrities(resolver:)
expanded_packages_and_directories.values.inject({}) do |map, file|
integrity = file.integrity || Rails.application.assets[file.path]&.integrity
if integrity
resolved_path = resolver.path_to_asset(file.path)
map.merge resolved_path => integrity
else
map
end
end
end

private
MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)

def cache_as(name)
if result = @cache[name.to_s]
Expand Down
22 changes: 17 additions & 5 deletions lib/importmap/packager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,23 @@ def import(*packages, env: "production", from: "jspm")
end
end

def pin_for(package, url)
%(pin "#{package}", to: "#{url}")
def pin_for(package, url, integrity: false)
if integrity
%(pin "#{package}", to: "#{url}", integrity: "#{integrity}")
else
%(pin "#{package}", to: "#{url}")
end
end

def vendored_pin_for(package, url)
def vendored_pin_for(package, url, integrity: false)
filename = package_filename(package)
version = extract_package_version_from(url)
integrity_suffix = %(, integrity: "#{integrity}") if integrity

if "#{package}.js" == filename
%(pin "#{package}" # #{version})
%(pin "#{package}"#{integrity_suffix} # #{version})
else
%(pin "#{package}", to: "#{filename}" # #{version})
%(pin "#{package}", to: "#{filename}"#{integrity_suffix} # #{version})
end
end

Expand All @@ -62,6 +67,13 @@ def remove(package)
remove_package_from_importmap(package)
end

def calculate_integrity(package: nil, url: nil)
contents = File.read(vendored_package_path(package)) if package
contents = Net::HTTP.get_response(URI(url)).body if url
integrity = Digest::SHA384.base64digest(contents)
"sha384-#{integrity}"
end

private
def post_json(body)
Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
Expand Down
1 change: 1 addition & 0 deletions test/dummy/app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log("Hello world")
2 changes: 1 addition & 1 deletion test/dummy/config/importmap.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pin_all_from "app/assets/javascripts"

pin "md5", to: "https://cdn.skypack.dev/md5", preload: true
pin "md5", to: "https://cdn.skypack.dev/md5", preload: true, integrity: "sha384-Z4mBXx9MNus/5gJoxoUytBtq6JEV77AuAnUdPedFRPuIB+puUQ2EE6LsC8bY9CBR"
pin "not_there", to: "nowhere.js"
24 changes: 18 additions & 6 deletions test/importmap_tags_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ def content_security_policy_nonce
end

test "javascript_importmap_tags with and without shim" do
assert_match /shim/, javascript_importmap_tags("application")
assert_no_match /shim/, javascript_importmap_tags("application", shim: false)
assert_match /es-module-shims/, javascript_importmap_tags("application")
assert_no_match /es-module-shims/, javascript_importmap_tags("application", shim: false)
end

test "javascript_inline_importmap_tag" do
assert_match \
%r{<script type="importmap" data-turbo-track="reload">{\n \"imports\": {\n \"md5\": \"https://cdn.skypack.dev/md5\",\n \"not_there\": \"/nowhere.js\"\n }\n}</script>},
%r{<script type="importmap-shim" data-turbo-track="reload">{\n \"imports\": {\n \"md5\": \"https://cdn.skypack.dev/md5\",\n \"not_there\": \"/nowhere.js\",\n \"application\": \"/application.js\"\n }\n}</script>},
javascript_inline_importmap_tag
end

Expand All @@ -48,7 +48,7 @@ def content_security_policy_nonce
@request = FakeRequest.new("iyhD0Yc0W+c=")

assert_match /nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag
assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_shim_nonce_configuration_tag
assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_shim_configuration_tag
assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_shim_tag
assert_match /nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application")
assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags
Expand All @@ -62,11 +62,23 @@ def content_security_policy_nonce
importmap.pin "bar", preload: false
importmap_html = javascript_importmap_tags("foo", importmap: importmap)

assert_includes importmap_html, %{<script type="importmap" data-turbo-track="reload">}
assert_includes importmap_html, %{<script type="importmap-shim" data-turbo-track="reload">}
assert_includes importmap_html, %{"foo": "/foo.js"}
assert_includes importmap_html, %{"bar": "/bar.js"}
assert_includes importmap_html, %{<link rel="modulepreload" href="/foo.js">}
refute_includes importmap_html, %{<link rel="modulepreload" href="/bar.js">}
assert_includes importmap_html, %{<script type="module">import "foo"</script>}
assert_includes importmap_html, %{<script type="module-shim">import "foo"</script>}
end

test "integrity shas are specified and integrity shim option is enabled if any pin has integrity" do
assert_dom_equal \
<<~HTML.chomp,
<link rel="modulepreload-shim" href="https://cdn.skypack.dev/md5" integrity="sha384-Z4mBXx9MNus/5gJoxoUytBtq6JEV77AuAnUdPedFRPuIB+puUQ2EE6LsC8bY9CBR">
<link rel="modulepreload-shim" href="/application.js" integrity="sha256-rtJxuS0yqe/8JI4gvbQHf7+EKTGA/a8Uk+J1wyIEBrE=">
HTML
javascript_importmap_integrity_tags
assert_dom_equal \
%(<script type="esms-options">{"enforceIntegrity":true}</script>),
javascript_importmap_shim_configuration_tag
end
end
8 changes: 8 additions & 0 deletions test/importmap_test.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "test_helper"
require "minitest/mock"

class ImportmapTest < ActiveSupport::TestCase
def setup
Expand All @@ -8,6 +9,7 @@ def setup
pin "editor", to: "rich_text.js"
pin "not_there", to: "nowhere.js"
pin "md5", to: "https://cdn.skypack.dev/md5", preload: true
pin "ciphers", to: "https://cdn.skypack.dev/ciphers", integrity: "sha384-/ZZpnm1H4nw1IuQda2fzk2EBThQwZz5aV3x84D3SqZMUCou2TU6WHhIjX0eSBK6S"

pin_all_from "app/javascript/controllers", under: "controllers", preload: true
pin_all_from "app/javascript/spina/controllers", under: "controllers/spina", preload: true
Expand All @@ -34,6 +36,12 @@ def setup
assert_equal "https://cdn.skypack.dev/md5", generate_importmap_json["imports"]["md5"]
end

test "remote with integrity is propagated" do
resolver = Minitest::Mock.new
def resolver.path_to_asset(a); a; end
assert_equal "sha384-/ZZpnm1H4nw1IuQda2fzk2EBThQwZz5aV3x84D3SqZMUCou2TU6WHhIjX0eSBK6S", @importmap.integrities(resolver: resolver)["https://cdn.skypack.dev/ciphers"]
end

test "directory pin mounted under matching subdir maps all files" do
assert_match %r|assets/controllers/goodbye_controller-.*\.js|, generate_importmap_json["imports"]["controllers/goodbye_controller"]
assert_match %r|assets/controllers/utilities/md5_controller-.*\.js|, generate_importmap_json["imports"]["controllers/utilities/md5_controller"]
Expand Down