Skip to content

Commit bb7cba6

Browse files
committed
Import the integrity hashes for packages from JSPM when pinning
And add a new command to download and add integrity hashes for packages.
1 parent cb618de commit bb7cba6

File tree

7 files changed

+469
-51
lines changed

7 files changed

+469
-51
lines changed

README.md

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import React from "./node_modules/react"
4444
import React from "https://ga.jspm.io/npm:[email protected]/index.js"
4545
```
4646

47-
Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
47+
Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
4848
to 1 of the 3 viable ways of loading ES Module javascript packages.
4949

5050
For example:
@@ -54,11 +54,11 @@ For example:
5454
pin "react", to: "https://ga.jspm.io/npm:[email protected]/index.js"
5555
```
5656

57-
means "everytime you see `import React from "react"`
57+
means "everytime you see `import React from "react"`
5858
change it to `import React from "https://ga.jspm.io/npm:[email protected]/index.js"`"
5959

6060
```js
61-
import React from "react"
61+
import React from "react"
6262
// => import React from "https://ga.jspm.io/npm:[email protected]/index.js"
6363
```
6464

@@ -131,6 +131,91 @@ If you later wish to remove a downloaded pin:
131131
Unpinning and removing "react"
132132
```
133133

134+
## Subresource Integrity (SRI)
135+
136+
For enhanced security, importmap-rails automatically includes [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes by default when pinning packages. This ensures that JavaScript files loaded from CDNs haven't been tampered with.
137+
138+
### Default behavior with integrity
139+
140+
When you pin a package, integrity hashes are automatically included:
141+
142+
```bash
143+
./bin/importmap pin lodash
144+
Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:[email protected]/lodash.js
145+
Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF
146+
```
147+
148+
This generates a pin in your `config/importmap.rb` with the integrity hash:
149+
150+
```ruby
151+
pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21
152+
```
153+
154+
### Opting out of integrity
155+
156+
If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag:
157+
158+
```bash
159+
./bin/importmap pin lodash --no-integrity
160+
Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:[email protected]/lodash.js
161+
```
162+
163+
This generates a pin without integrity:
164+
165+
```ruby
166+
pin "lodash" # @4.17.21
167+
```
168+
169+
### Adding integrity to existing pins
170+
171+
If you have existing pins without integrity hashes, you can add them using the `integrity` command:
172+
173+
```bash
174+
# Add integrity to specific packages
175+
./bin/importmap integrity lodash react
176+
177+
# Add integrity to all pinned packages
178+
./bin/importmap integrity
179+
180+
# Update your importmap.rb file with integrity hashes
181+
./bin/importmap integrity --update
182+
```
183+
184+
### How integrity works
185+
186+
The integrity hashes are automatically included in your import map and module preload tags:
187+
188+
**Import map JSON:**
189+
```json
190+
{
191+
"imports": {
192+
"lodash": "https://ga.jspm.io/npm:[email protected]/lodash.js"
193+
},
194+
"integrity": {
195+
"https://ga.jspm.io/npm:[email protected]/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
196+
}
197+
}
198+
```
199+
200+
**Module preload tags:**
201+
```html
202+
<link rel="modulepreload" href="https://ga.jspm.io/npm:[email protected]/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
203+
```
204+
205+
Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.
206+
207+
### Redownloading packages with integrity
208+
209+
The `pristine` command also includes integrity by default:
210+
211+
```bash
212+
# Redownload all packages with integrity (default)
213+
./bin/importmap pristine
214+
215+
# Redownload packages without integrity
216+
./bin/importmap pristine --no-integrity
217+
```
218+
134219
## Preloading pinned modules
135220

136221
To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, importmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin.

lib/importmap/commands.rb

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,55 +13,51 @@ def self.exit_on_failure?
1313
option :env, type: :string, aliases: :e, default: "production"
1414
option :from, type: :string, aliases: :f, default: "jspm"
1515
option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
16+
option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
1617
def pin(*packages)
17-
if imports = packager.import(*packages, env: options[:env], from: options[:from])
18-
imports.each do |package, url|
18+
with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
19+
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
1920
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
21+
2022
packager.download(package, url)
21-
pin = packager.vendored_pin_for(package, url, options[:preload])
2223

23-
if packager.packaged?(package)
24-
gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
25-
else
26-
append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
27-
end
24+
pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash)
25+
26+
log_integrity_usage(integrity_hash)
27+
update_importmap_with_pin(package, pin)
2828
end
29-
else
30-
puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
3129
end
3230
end
3331

3432
desc "unpin [*PACKAGES]", "Unpin existing packages"
3533
option :env, type: :string, aliases: :e, default: "production"
3634
option :from, type: :string, aliases: :f, default: "jspm"
3735
def unpin(*packages)
38-
if imports = packager.import(*packages, env: options[:env], from: options[:from])
36+
with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes|
3937
imports.each do |package, url|
4038
if packager.packaged?(package)
4139
puts %(Unpinning and removing "#{package}")
4240
packager.remove(package)
4341
end
4442
end
45-
else
46-
puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
4743
end
4844
end
4945

5046
desc "pristine", "Redownload all pinned packages"
5147
option :env, type: :string, aliases: :e, default: "production"
5248
option :from, type: :string, aliases: :f, default: "jspm"
49+
option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
5350
def pristine
54-
packages = npm.packages_with_versions.map do |p, v|
55-
v.blank? ? p : [p, v].join("@")
56-
end
51+
packages = prepare_packages_with_versions
5752

58-
if imports = packager.import(*packages, env: options[:env], from: options[:from])
59-
imports.each do |package, url|
53+
with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
54+
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
6055
puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
56+
6157
packager.download(package, url)
58+
59+
log_integrity_usage(integrity_hash)
6260
end
63-
else
64-
puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
6561
end
6662
end
6763

@@ -122,6 +118,33 @@ def packages
122118
puts npm.packages_with_versions.map { |x| x.join(' ') }
123119
end
124120

121+
desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages"
122+
option :env, type: :string, aliases: :e, default: "production"
123+
option :from, type: :string, aliases: :f, default: "jspm"
124+
option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes"
125+
def integrity(*packages)
126+
packages = prepare_packages_with_versions(packages)
127+
128+
with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes|
129+
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
130+
puts %(Getting integrity for "#{package}" from #{url})
131+
132+
if integrity_hash
133+
puts %( #{package}: #{integrity_hash})
134+
135+
if options[:update]
136+
pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash)
137+
138+
update_importmap_with_pin(package, pin_with_integrity)
139+
puts %( Updated importmap.rb with integrity for "#{package}")
140+
end
141+
else
142+
puts %( No integrity hash available for "#{package}")
143+
end
144+
end
145+
end
146+
end
147+
125148
private
126149
def packager
127150
@packager ||= Importmap::Packager.new
@@ -131,6 +154,22 @@ def npm
131154
@npm ||= Importmap::Npm.new
132155
end
133156

157+
def update_importmap_with_pin(package, pin)
158+
if packager.packaged?(package)
159+
gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
160+
else
161+
append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
162+
end
163+
end
164+
165+
def log_integrity_usage(integrity_hash)
166+
puts %( Using integrity: #{integrity_hash}) if integrity_hash
167+
end
168+
169+
def handle_package_not_found(packages, from)
170+
puts "Couldn't find any packages in #{packages.inspect} on #{from}"
171+
end
172+
134173
def remove_line_from_file(path, pattern)
135174
path = File.expand_path(path, destination_root)
136175

@@ -155,6 +194,33 @@ def puts_table(array)
155194
puts divider if row_number == 0
156195
end
157196
end
197+
198+
def prepare_packages_with_versions(packages = [])
199+
if packages.empty?
200+
npm.packages_with_versions.map do |p, v|
201+
v.blank? ? p : [p, v].join("@")
202+
end
203+
else
204+
packages
205+
end
206+
end
207+
208+
def process_imports(imports, integrity_hashes, &block)
209+
imports.each do |package, url|
210+
integrity_hash = integrity_hashes[url]
211+
block.call(package, url, integrity_hash)
212+
end
213+
end
214+
215+
def with_import_response(packages, **options)
216+
response = packager.import(*packages, **options)
217+
218+
if response
219+
yield response[:imports], response[:integrity]
220+
else
221+
handle_package_not_found(packages, options[:from])
222+
end
223+
end
158224
end
159225

160226
Importmap::Commands.start(ARGV)

lib/importmap/packager.rb

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,34 +17,39 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java
1717
@vendor_path = Pathname.new(vendor_path)
1818
end
1919

20-
def import(*packages, env: "production", from: "jspm")
20+
def import(*packages, env: "production", from: "jspm", integrity: false)
2121
response = post_json({
2222
"install" => Array(packages),
2323
"flattenScope" => true,
2424
"env" => [ "browser", "module", env ],
25-
"provider" => normalize_provider(from)
25+
"provider" => normalize_provider(from),
26+
"integrity" => integrity
2627
})
2728

2829
case response.code
29-
when "200" then extract_parsed_imports(response)
30-
when "404", "401" then nil
31-
else handle_failure_response(response)
30+
when "200"
31+
extract_parsed_response(response)
32+
when "404", "401"
33+
nil
34+
else
35+
handle_failure_response(response)
3236
end
3337
end
3438

35-
def pin_for(package, url)
36-
%(pin "#{package}", to: "#{url}")
39+
def pin_for(package, url = nil, preloads: nil, integrity: nil)
40+
to = url ? %(, to: "#{url}") : ""
41+
preload_param = preload(preloads)
42+
integrity_param = integrity ? %(, integrity: "#{integrity}") : ""
43+
44+
%(pin "#{package}") + to + preload_param + integrity_param
3745
end
3846

39-
def vendored_pin_for(package, url, preloads = nil)
47+
def vendored_pin_for(package, url, preloads = nil, integrity: nil)
4048
filename = package_filename(package)
4149
version = extract_package_version_from(url)
50+
to = "#{package}.js" != filename ? filename : nil
4251

43-
if "#{package}.js" == filename
44-
%(pin "#{package}"#{preload(preloads)} # #{version})
45-
else
46-
%(pin "#{package}", to: "#{filename}"#{preload(preloads)} # #{version})
47-
end
52+
pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version})
4853
end
4954

5055
def packaged?(package)
@@ -88,8 +93,15 @@ def normalize_provider(name)
8893
name.to_s == "jspm" ? "jspm.io" : name.to_s
8994
end
9095

91-
def extract_parsed_imports(response)
92-
JSON.parse(response.body).dig("map", "imports")
96+
def extract_parsed_response(response)
97+
parsed = JSON.parse(response.body)
98+
imports = parsed.dig("map", "imports")
99+
integrity = parsed.dig("map", "integrity") || {}
100+
101+
{
102+
imports: imports,
103+
integrity: integrity
104+
}
93105
end
94106

95107
def handle_failure_response(response)

0 commit comments

Comments
 (0)