Skip to content

Commit 9bcad5f

Browse files
authored
Merge pull request #238 from zcei/feat/integrity
Add Subresource Integrity value to manifest
2 parents 22cca8f + 6c47eb2 commit 9bcad5f

23 files changed

+772
-48
lines changed

README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ With Rails 8, Propshaft is the default asset pipeline for new applications. With
1616

1717
## Usage
1818

19-
Propshaft makes all the assets from all the paths it's been configured with through `config.assets.paths` available for serving and will copy all of them into `public/assets` when precompiling. This is unlike Sprockets, which did not copy over assets that hadn't been explicitly included in one of the bundled assets.
19+
Propshaft makes all the assets from all the paths it's been configured with through `config.assets.paths` available for serving and will copy all of them into `public/assets` when precompiling. This is unlike Sprockets, which did not copy over assets that hadn't been explicitly included in one of the bundled assets.
2020

2121
You can however exempt directories that have been added through the `config.assets.excluded_paths`. This is useful if you're for example using `app/assets/stylesheets` exclusively as a set of inputs to a compiler like Dart Sass for Rails, and you don't want these input files to be part of the load path. (Remember you need to add full paths, like `Rails.root.join("app/assets/stylesheets")`).
2222

@@ -50,9 +50,55 @@ export default class extends Controller {
5050

5151
If you need to put multiple files that refer to each other through Propshaft, like a JavaScript file and its source map, you have to digest these files in advance to retain stable file names. Propshaft looks for the specific pattern of `-[digest].digested.js` as the postfix to any asset file as an indication that the file has already been digested.
5252

53+
## Subresource Integrity (SRI)
54+
55+
Propshaft supports Subresource Integrity (SRI) to help protect against malicious modifications of assets. SRI allows browsers to verify that resources fetched from CDNs or other sources haven't been tampered with by checking cryptographic hashes.
56+
57+
### Enabling SRI
58+
59+
To enable SRI support, configure the hash algorithm in your Rails application:
60+
61+
```ruby
62+
config.assets.integrity_hash_algorithm = "sha384"
63+
```
64+
65+
Valid hash algorithms include:
66+
- `"sha256"` - SHA-256 (most common)
67+
- `"sha384"` - SHA-384 (recommended for enhanced security)
68+
- `"sha512"` - SHA-512 (strongest)
69+
70+
### Using SRI in your views
71+
72+
Once configured, you can enable SRI by passing the `integrity: true` option to asset helpers:
73+
74+
```erb
75+
<%= stylesheet_link_tag "application", integrity: true %>
76+
<%= javascript_include_tag "application", integrity: true %>
77+
```
78+
79+
This generates HTML with integrity hashes:
80+
81+
```html
82+
<link rel="stylesheet" href="/assets/application-abc123.css"
83+
integrity="sha384-xyz789...">
84+
<script src="/assets/application-def456.js"
85+
integrity="sha384-uvw012..."></script>
86+
```
87+
88+
**Important**: SRI only works in secure contexts (HTTPS) or during local development. The integrity hashes are automatically omitted when serving over HTTP in production for security reasons.
89+
90+
### Bulk stylesheet inclusion with SRI
91+
92+
Propshaft extends `stylesheet_link_tag` with special symbols for bulk inclusion:
93+
94+
```erb
95+
<%= stylesheet_link_tag :all, integrity: true %> <!-- All stylesheets -->
96+
<%= stylesheet_link_tag :app, integrity: true %> <!-- Only app/assets stylesheets -->
97+
```
98+
5399
## Improving performance in development
54100

55-
Before every request Propshaft checks if any asset was updated to decide if a cache sweep is needed. This verification is done using the application's configured file watcher which, by default, is `ActiveSupport::FileUpdateChecker`.
101+
Before every request Propshaft checks if any asset was updated to decide if a cache sweep is needed. This verification is done using the application's configured file watcher which, by default, is `ActiveSupport::FileUpdateChecker`.
56102

57103
If you have a lot of assets in your project, you can improve performance by adding the `listen` gem to the development group in your Gemfile, and this line to the `development.rb` environment file:
58104

lib/propshaft/assembly.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "propshaft/manifest"
12
require "propshaft/load_path"
23
require "propshaft/resolver/dynamic"
34
require "propshaft/resolver/static"
@@ -16,7 +17,13 @@ def initialize(config)
1617
end
1718

1819
def load_path
19-
@load_path ||= Propshaft::LoadPath.new(config.paths, compilers: compilers, version: config.version, file_watcher: config.file_watcher)
20+
@load_path ||= Propshaft::LoadPath.new(
21+
config.paths,
22+
compilers: compilers,
23+
version: config.version,
24+
file_watcher: config.file_watcher,
25+
integrity_hash_algorithm: config.integrity_hash_algorithm
26+
)
2027
end
2128

2229
def resolver

lib/propshaft/asset.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "digest/sha1"
2+
require "digest/sha2"
23
require "action_dispatch/http/mime_type"
34

45
class Propshaft::Asset
@@ -17,6 +18,10 @@ def initialize(path, logical_path:, load_path:)
1718
@path, @logical_path, @load_path = path, Pathname.new(logical_path), load_path
1819
end
1920

21+
def compiled_content
22+
@compiled_content ||= load_path.compilers.compile(self)
23+
end
24+
2025
def content(encoding: "ASCII-8BIT")
2126
File.read(path, encoding: encoding, mode: "rb")
2227
end
@@ -33,6 +38,24 @@ def digest
3338
@digest ||= Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8)
3439
end
3540

41+
def integrity(hash_algorithm:)
42+
# Following the Subresource Integrity spec draft
43+
# https://w3c.github.io/webappsec-subresource-integrity/
44+
# allowing only sha256, sha384, and sha512
45+
bitlen = case hash_algorithm
46+
when "sha256"
47+
256
48+
when "sha384"
49+
384
50+
when "sha512"
51+
512
52+
else
53+
raise(StandardError.new("Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)"))
54+
end
55+
56+
[hash_algorithm, Digest::SHA2.new(bitlen).base64digest(compiled_content)].join("-")
57+
end
58+
3659
def digested_path
3760
if already_digested?
3861
logical_path

lib/propshaft/helper.rb

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,102 @@
11
module Propshaft
2+
# Helper module that provides asset path resolution and integrity support for Rails applications.
3+
#
4+
# This module extends Rails' built-in asset helpers with additional functionality:
5+
# - Subresource Integrity (SRI) support for enhanced security
6+
# - Bulk stylesheet inclusion with :all and :app options
7+
# - Asset path resolution with proper error handling
8+
#
9+
# == Subresource Integrity (SRI) Support
10+
#
11+
# SRI helps protect against malicious modifications of assets by ensuring that
12+
# resources fetched from CDNs or other sources haven't been tampered with.
13+
#
14+
# SRI is automatically enabled in secure contexts (HTTPS or local development)
15+
# when the 'integrity' option is set to true:
16+
#
17+
# <%= stylesheet_link_tag "application", integrity: true %>
18+
# <%= javascript_include_tag "application", integrity: true %>
19+
#
20+
# This will generate integrity hashes and include them in the HTML:
21+
#
22+
# <link rel="stylesheet" href="/assets/application-abc123.css"
23+
# integrity="sha256-xyz789...">
24+
# <script src="/assets/application-def456.js"
25+
# integrity="sha256-uvw012..."></script>
26+
#
27+
# == Bulk Stylesheet Inclusion
28+
#
29+
# The stylesheet_link_tag helper supports special symbols for bulk inclusion:
30+
# - :all - includes all CSS files found in the load path
31+
# - :app - includes only CSS files from app/assets/**/*.css
32+
#
33+
# <%= stylesheet_link_tag :all %> # All stylesheets
34+
# <%= stylesheet_link_tag :app %> # Only app stylesheets
235
module Helper
36+
# Computes the Subresource Integrity (SRI) hash for the given asset path.
37+
#
38+
# This method generates a cryptographic hash of the asset content that can be used
39+
# to verify the integrity of the resource when it's loaded by the browser.
40+
#
41+
# asset_integrity("application.css")
42+
# # => "sha256-xyz789abcdef..."
43+
def asset_integrity(path, options = {})
44+
path = _path_with_extname(path, options)
45+
Rails.application.assets.resolver.integrity(path)
46+
end
47+
48+
# Resolves the full path for an asset, raising an error if not found.
349
def compute_asset_path(path, options = {})
450
Rails.application.assets.resolver.resolve(path) || raise(MissingAssetError.new(path))
551
end
652

7-
# Add an option to call `stylesheet_link_tag` with `:all` to include every css file found on the load path
8-
# or `:app` to include css files found in `Rails.root("app/assets/**/*.css")`, which will exclude lib/ and plugins.
53+
# Enhanced +stylesheet_link_tag+ with integrity support and bulk inclusion options.
54+
#
55+
# In addition to the standard Rails functionality, this method supports:
56+
# * Automatic SRI (Subresource Integrity) hash generation in secure contexts
57+
# * Add an option to call +stylesheet_link_tag+ with +:all+ to include every css
58+
# file found on the load path or +:app+ to include css files found in
59+
# <tt>Rails.root("app/assets/**/*.css")</tt>, which will exclude lib/ and plugins.
60+
#
61+
# ==== Options
62+
#
63+
# * <tt>:integrity</tt> - Enable SRI hash generation
64+
#
65+
# ==== Examples
66+
#
67+
# stylesheet_link_tag "application", integrity: true
68+
# # => <link rel="stylesheet" href="/assets/application-abc123.css"
69+
# # integrity="sha256-xyz789...">
70+
#
71+
# stylesheet_link_tag :all # All stylesheets in load path
72+
# stylesheet_link_tag :app # Only app/assets stylesheets
973
def stylesheet_link_tag(*sources, **options)
1074
case sources.first
1175
when :all
12-
super(*all_stylesheets_paths , **options)
76+
sources = all_stylesheets_paths
1377
when :app
14-
super(*app_stylesheets_paths , **options)
15-
else
16-
super
78+
sources = app_stylesheets_paths
1779
end
80+
81+
_build_asset_tags(sources, options, :stylesheet) { |source, opts| super(source, opts) }
82+
end
83+
84+
# Enhanced +javascript_include_tag+ with automatic SRI (Subresource Integrity) support.
85+
#
86+
# This method extends Rails' built-in +javascript_include_tag+ to automatically
87+
# generate and include integrity hashes when running in secure contexts.
88+
#
89+
# ==== Options
90+
#
91+
# * <tt>:integrity</tt> - Enable SRI hash generation
92+
#
93+
# ==== Examples
94+
#
95+
# javascript_include_tag "application", integrity: true
96+
# # => <script src="/assets/application-abc123.js"
97+
# # integrity="sha256-xyz789..."></script>
98+
def javascript_include_tag(*sources, **options)
99+
_build_asset_tags(sources, options, :javascript) { |source, opts| super(source, opts) }
18100
end
19101

20102
# Returns a sorted and unique array of logical paths for all stylesheets in the load path.
@@ -26,5 +108,50 @@ def all_stylesheets_paths
26108
def app_stylesheets_paths
27109
Rails.application.assets.load_path.asset_paths_by_glob("#{Rails.root.join("app/assets")}/**/*.css")
28110
end
111+
112+
private
113+
# Core method that builds asset tags with optional integrity support.
114+
#
115+
# This method handles the common logic for both +stylesheet_link_tag+ and
116+
# +javascript_include_tag+, including SRI hash generation and HTML tag creation.
117+
def _build_asset_tags(sources, options, asset_type)
118+
options = options.stringify_keys
119+
integrity = _compute_integrity?(options)
120+
121+
sources.map { |source|
122+
opts = integrity ? options.merge!('integrity' => asset_integrity(source, type: asset_type)) : options
123+
yield(source, opts)
124+
}.join("\n").html_safe
125+
end
126+
127+
# Determines whether integrity hashes should be computed for assets.
128+
#
129+
# Integrity is only computed in secure contexts (HTTPS or local development)
130+
# and when explicitly requested via the +integrity+ option.
131+
def _compute_integrity?(options)
132+
if _secure_subresource_integrity_context?
133+
case options['integrity']
134+
when nil, false, true
135+
options.delete('integrity') == true
136+
end
137+
else
138+
options.delete 'integrity'
139+
false
140+
end
141+
end
142+
143+
# Checks if the current context is secure enough for Subresource Integrity.
144+
#
145+
# SRI is only beneficial in secure contexts. Returns true when:
146+
# * The request is made over HTTPS (SSL), OR
147+
# * The request is local (development environment)
148+
def _secure_subresource_integrity_context?
149+
respond_to?(:request) && self.request && (self.request.local? || self.request.ssl?)
150+
end
151+
152+
# Ensures the asset path includes the appropriate file extension.
153+
def _path_with_extname(path, options)
154+
"#{path}#{compute_asset_extname(path, options)}"
155+
end
29156
end
30157
end

lib/propshaft/load_path.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "propshaft/manifest"
12
require "propshaft/asset"
23

34
class Propshaft::LoadPath
@@ -11,10 +12,10 @@ def execute_if_updated
1112
end
1213
end
1314

14-
attr_reader :paths, :compilers, :version
15+
attr_reader :paths, :compilers, :version, :integrity_hash_algorithm
1516

16-
def initialize(paths = [], compilers:, version: nil, file_watcher: nil)
17-
@paths, @compilers, @version = dedup(paths), compilers, version
17+
def initialize(paths = [], compilers:, version: nil, file_watcher: nil, integrity_hash_algorithm: nil)
18+
@paths, @compilers, @version, @integrity_hash_algorithm = dedup(paths), compilers, version, integrity_hash_algorithm
1819
@file_watcher = file_watcher || NullFileWatcher
1920
end
2021

@@ -41,10 +42,8 @@ def asset_paths_by_glob(glob)
4142
end
4243

4344
def manifest
44-
Hash.new.tap do |manifest|
45-
assets.each do |asset|
46-
manifest[asset.logical_path.to_s] = asset.digested_path.to_s
47-
end
45+
Propshaft::Manifest.new(integrity_hash_algorithm: integrity_hash_algorithm).tap do |manifest|
46+
assets.each { |asset| manifest.push_asset(asset) }
4847
end
4948
end
5049

0 commit comments

Comments
 (0)