Skip to content

Implement SRI support in importmap-rails #304

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:

env:
BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails-version }}_${{ matrix.assets-pipeline }}.gemfile
ASSETS_PIPELINE: ${{ matrix.assets-pipeline }}

steps:
- uses: actions/checkout@v4
Expand Down
5 changes: 0 additions & 5 deletions Appraisals
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ end

appraise "rails_7.0_propshaft" do
gem "rails", github: "rails/rails", branch: "7-0-stable"
gem "propshaft"
gem "sqlite3", "~> 1.4"
end

Expand All @@ -29,7 +28,6 @@ end

appraise "rails_7.1_propshaft" do
gem "rails", "~> 7.1.0"
gem "propshaft"
end

appraise "rails_7.2_sprockets" do
Expand All @@ -40,7 +38,6 @@ end

appraise "rails_7.2_propshaft" do
gem "rails", "~> 7.2.0"
gem "propshaft"
end

appraise "rails_8.0_sprockets" do
Expand All @@ -51,7 +48,6 @@ end

appraise "rails_8.0_propshaft" do
gem "rails", "~> 8.0.0"
gem "propshaft"
end

appraise "rails_main_sprockets" do
Expand All @@ -62,5 +58,4 @@ end

appraise "rails_main_propshaft" do
gem "rails", github: "rails/rails", branch: "main"
gem "propshaft"
end
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec

gem "rails"
gem "propshaft"
gem "propshaft", ">= 1.2.0"

gem "sqlite3"

Expand Down
9 changes: 4 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,13 @@ GEM
crass (1.0.6)
date (3.4.1)
drb (2.2.3)
erb (5.0.1)
erb (5.0.2)
erubi (1.13.1)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.0)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
Expand Down Expand Up @@ -150,11 +150,10 @@ GEM
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
propshaft (1.1.0)
propshaft (1.2.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.6)
date
stringio
Expand Down Expand Up @@ -257,7 +256,7 @@ DEPENDENCIES
byebug
capybara
importmap-rails!
propshaft
propshaft (>= 1.2.0)
rails
rexml
selenium-webdriver
Expand Down
144 changes: 140 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import React from "./node_modules/react"
import React from "https://ga.jspm.io/npm:[email protected]/index.js"
```

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

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

means "everytime you see `import React from "react"`
means "every time you see `import React from "react"`
change it to `import React from "https://ga.jspm.io/npm:[email protected]/index.js"`"

```js
import React from "react"
import React from "react"
// => import React from "https://ga.jspm.io/npm:[email protected]/index.js"
```

Expand All @@ -79,10 +79,15 @@ If you want to import local js module files from `app/javascript/src` or other s
```rb
# config/importmap.rb
pin_all_from 'app/javascript/src', under: 'src', to: 'src'

# With automatic integrity calculation for enhanced security
pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true
```

The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter.

The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management.

Allows you to:

```js
Expand Down Expand Up @@ -131,6 +136,137 @@ If you later wish to remove a downloaded pin:
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
```

### 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:

```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

# Mixed usage
pin "local_module", integrity: true # Auto-calculated
pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated
pin "no_integrity_package" # No integrity (default)
```

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)

**Example output with `integrity: true`:**
```json
{
"imports": {
"application": "/assets/application-abc123.js",
"controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
},
"integrity": {
"/assets/application-abc123.js": "sha256-xyz789...",
"/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
}
}
```

### How integrity works

The integrity hashes are automatically included in your import map and module preload tags:

**Import map JSON:**
```json
{
"imports": {
"lodash": "https://ga.jspm.io/npm:[email protected]/lodash.js"
},
"integrity": {
"https://ga.jspm.io/npm:[email protected]/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF"
}
}
```

**Module preload tags:**
```html
<link rel="modulepreload" href="https://ga.jspm.io/npm:[email protected]/lodash.js" integrity="sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF">
```

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 Expand Up @@ -217,7 +353,7 @@ Pin your js file:
pin "checkout", preload: false
```

Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specifc page/partial, then yield it in your layout.
Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specific page/partial, then yield it in your layout.

```erb
<% content_for :head do %>
Expand Down
18 changes: 14 additions & 4 deletions app/helpers/importmap/importmap_tags_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,23 @@ def javascript_import_module_tag(*module_names)
# (defaults to Rails.application.importmap), such that they'll be fetched
# in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload).
def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application")
javascript_module_preload_tag(*importmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point))
packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point)

_generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] }
end

# Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element.
def javascript_module_preload_tag(*paths)
safe_join(Array(paths).collect { |path|
tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce
}, "\n")
_generate_preload_tags(paths) { |path| [path, {}] }
end

private
def _generate_preload_tags(items)
content_security_policy_nonce = request&.content_security_policy_nonce

safe_join(Array(items).collect { |item|
path, options = yield(item)
tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options
}, "\n")
end
end
2 changes: 1 addition & 1 deletion gemfiles/rails_7.0_propshaft.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
source "https://rubygems.org"

gem "rails", branch: "7-0-stable", git: "https://github.com/rails/rails.git"
gem "propshaft"
gem "propshaft", ">= 1.2.0"
gem "sqlite3", "~> 1.4"

group :development do
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/rails_7.1_propshaft.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
source "https://rubygems.org"

gem "rails", "~> 7.1.0"
gem "propshaft"
gem "propshaft", ">= 1.2.0"
gem "sqlite3"

group :development do
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/rails_7.2_propshaft.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
source "https://rubygems.org"

gem "rails", "~> 7.2.0"
gem "propshaft"
gem "propshaft", ">= 1.2.0"
gem "sqlite3"

group :development do
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/rails_8.0_propshaft.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
source "https://rubygems.org"

gem "rails", "~> 8.0.0"
gem "propshaft"
gem "propshaft", ">= 1.2.0"
gem "sqlite3"

group :development do
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/rails_main_propshaft.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
source "https://rubygems.org"

gem "rails", branch: "main", git: "https://github.com/rails/rails.git"
gem "propshaft"
gem "propshaft", ">= 1.2.0"
gem "sqlite3"

group :development do
Expand Down
Loading