Skip to content

Improve SRI support #309

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

Merged
merged 1 commit into from
Jul 30, 2025
Merged
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
104 changes: 27 additions & 77 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,85 +138,41 @@ Unpinning and removing "react"

## Subresource Integrity (SRI)

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.

### Default behavior with integrity

When you pin a package, integrity hashes are automatically included:

```bash
./bin/importmap pin lodash
Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:[email protected]/lodash.js
Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF
```

This generates a pin in your `config/importmap.rb` with the integrity hash:

```ruby
pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21
```

### Opting out of integrity

If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag:

```bash
./bin/importmap pin lodash --no-integrity
Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:[email protected]/lodash.js
```

This generates a pin without integrity:

```ruby
pin "lodash" # @4.17.21
```

### Adding integrity to existing pins

If you have existing pins without integrity hashes, you can add them using the `integrity` command:

```bash
# Add integrity to specific packages
./bin/importmap integrity lodash react

# Add integrity to all pinned packages
./bin/importmap integrity

# Update your importmap.rb file with integrity hashes
./bin/importmap integrity --update
```
For enhanced security, importmap-rails supports [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes for packages loaded from external CDNs.

### Automatic integrity for local assets

For local assets served by the Rails asset pipeline (like those created with `pin` or `pin_all_from`), you can use `integrity: true` to automatically calculate integrity hashes from the compiled assets:
Starting with importmap-rails, **`integrity: true` is the default** for all pins. This automatically calculates integrity hashes for local assets served by the Rails asset pipeline:

```ruby
# config/importmap.rb

# Automatically calculate integrity from asset pipeline
pin "application", integrity: true
pin "admin", to: "admin.js", integrity: true

# Works with pin_all_from too
pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
pin_all_from "app/javascript/lib", under: "lib", integrity: true
# These all use integrity: true by default
pin "application" # Auto-calculated integrity
pin "admin", to: "admin.js" # Auto-calculated integrity
pin_all_from "app/javascript/controllers", under: "controllers" # Auto-calculated integrity

# Mixed usage
pin "local_module", integrity: true # Auto-calculated
pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated
pin "no_integrity_package" # No integrity (default)
# Mixed usage - explicitly controlling integrity
pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated hash
pin "no_integrity_package", integrity: false # Explicitly disable integrity
pin "nil_integrity_package", integrity: nil # Explicitly disable integrity
```

This is particularly useful for:
* **Local JavaScript files** managed by your Rails asset pipeline
* **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
* **Development workflow** where asset contents change frequently

The `integrity: true` option:
* Uses the Rails asset pipeline's built-in integrity calculation
* Works with both Sprockets and Propshaft
* Automatically updates when assets are recompiled
* Gracefully handles missing assets (returns `nil` for non-existent files)
This behavior can be disabled by setting `integrity: false` or `integrity: nil`

**Important for Propshaft users:** SRI support requires Propshaft 1.2+ and you must configure the integrity hash algorithm in your application:

```ruby
# config/application.rb or config/environments/*.rb
config.assets.integrity_hash_algorithm = 'sha256' # or 'sha384', 'sha512'
```

Without this configuration, integrity will be disabled by default when using Propshaft. Sprockets includes integrity support out of the box.

**Example output with `integrity: true`:**
```json
Expand All @@ -240,33 +196,27 @@ The integrity hashes are automatically included in your import map and module pr
```json
{
"imports": {
"lodash": "https://ga.jspm.io/npm:[email protected]/lodash.js"
"lodash": "https://ga.jspm.io/npm:[email protected]/lodash.js",
"application": "/assets/application-abc123.js",
"controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
},
"integrity": {
"https://ga.jspm.io/npm:[email protected]/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
"/assets/application-abc123.js": "sha256-xyz789...",
"/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
}
}
```

**Module preload tags:**
```html
<link rel="modulepreload" href="https://ga.jspm.io/npm:[email protected]/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
<link rel="modulepreload" href="/assets/application-abc123.js" integrity="sha256-xyz789...">
<link rel="modulepreload" href="/assets/controllers/hello_controller-def456.js" integrity="sha256-uvw012...">
```

Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified.

### Redownloading packages with integrity

The `pristine` command also includes integrity by default:

```bash
# Redownload all packages with integrity (default)
./bin/importmap pristine

# Redownload packages without integrity
./bin/importmap pristine --no-integrity
```

## Preloading pinned modules

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.
Expand Down
77 changes: 14 additions & 63 deletions lib/importmap/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,51 +13,40 @@ def self.exit_on_failure?
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
def pin(*packages)
with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})
for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url})

packager.download(package, url)
packager.download(package, url)

pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash)
pin = packager.vendored_pin_for(package, url, options[:preload])

log_integrity_usage(integrity_hash)
update_importmap_with_pin(package, pin)
end
update_importmap_with_pin(package, pin)
end
end

desc "unpin [*PACKAGES]", "Unpin existing packages"
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
def unpin(*packages)
with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes|
imports.each do |package, url|
if packager.packaged?(package)
puts %(Unpinning and removing "#{package}")
packager.remove(package)
end
for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
if packager.packaged?(package)
puts %(Unpinning and removing "#{package}")
packager.remove(package)
end
end
end

desc "pristine", "Redownload all pinned packages"
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM"
def pristine
packages = prepare_packages_with_versions

with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes|
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})
for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url})

packager.download(package, url)

log_integrity_usage(integrity_hash)
end
packager.download(package, url)
end
end

Expand Down Expand Up @@ -118,33 +107,6 @@ def packages
puts npm.packages_with_versions.map { |x| x.join(' ') }
end

desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages"
option :env, type: :string, aliases: :e, default: "production"
option :from, type: :string, aliases: :f, default: "jspm"
option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes"
def integrity(*packages)
packages = prepare_packages_with_versions(packages)

with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes|
process_imports(imports, integrity_hashes) do |package, url, integrity_hash|
puts %(Getting integrity for "#{package}" from #{url})

if integrity_hash
puts %( #{package}: #{integrity_hash})

if options[:update]
pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash)

update_importmap_with_pin(package, pin_with_integrity)
puts %( Updated importmap.rb with integrity for "#{package}")
end
else
puts %( No integrity hash available for "#{package}")
end
end
end
end

private
def packager
@packager ||= Importmap::Packager.new
Expand All @@ -162,10 +124,6 @@ def update_importmap_with_pin(package, pin)
end
end

def log_integrity_usage(integrity_hash)
puts %( Using integrity: #{integrity_hash}) if integrity_hash
end

def handle_package_not_found(packages, from)
puts "Couldn't find any packages in #{packages.inspect} on #{from}"
end
Expand Down Expand Up @@ -205,18 +163,11 @@ def prepare_packages_with_versions(packages = [])
end
end

def process_imports(imports, integrity_hashes, &block)
imports.each do |package, url|
integrity_hash = integrity_hashes[url]
block.call(package, url, integrity_hash)
end
end

def with_import_response(packages, **options)
def for_each_import(packages, **options, &block)
response = packager.import(*packages, **options)

if response
yield response[:imports], response[:integrity]
response[:imports].each(&block)
else
handle_package_not_found(packages, options[:from])
end
Expand Down
4 changes: 2 additions & 2 deletions lib/importmap/map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ def draw(path = nil, &block)
self
end

def pin(name, to: nil, preload: true, integrity: nil)
def pin(name, to: nil, preload: true, integrity: true)
clear_cache
@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: true, integrity: nil)
def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: true)
clear_cache
@directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
end
Expand Down
14 changes: 5 additions & 9 deletions lib/importmap/packager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java
@vendor_path = Pathname.new(vendor_path)
end

def import(*packages, env: "production", from: "jspm", integrity: false)
def import(*packages, env: "production", from: "jspm")
response = post_json({
"install" => Array(packages),
"flattenScope" => true,
"env" => [ "browser", "module", env ],
"provider" => normalize_provider(from),
"integrity" => integrity
})

case response.code
Expand All @@ -36,20 +35,19 @@ def import(*packages, env: "production", from: "jspm", integrity: false)
end
end

def pin_for(package, url = nil, preloads: nil, integrity: nil)
def pin_for(package, url = nil, preloads: nil)
to = url ? %(, to: "#{url}") : ""
preload_param = preload(preloads)
integrity_param = integrity ? %(, integrity: "#{integrity}") : ""

%(pin "#{package}") + to + preload_param + integrity_param
%(pin "#{package}") + to + preload_param
end

def vendored_pin_for(package, url, preloads = nil, integrity: nil)
def vendored_pin_for(package, url, preloads = nil)
filename = package_filename(package)
version = extract_package_version_from(url)
to = "#{package}.js" != filename ? filename : nil

pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version})
pin_for(package, to, preloads: preloads) + %( # #{version})
end

def packaged?(package)
Expand Down Expand Up @@ -96,11 +94,9 @@ def normalize_provider(name)
def extract_parsed_response(response)
parsed = JSON.parse(response.body)
imports = parsed.dig("map", "imports")
integrity = parsed.dig("map", "integrity") || {}

{
imports: imports,
integrity: integrity
}
end

Expand Down
Loading