Skip to content
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
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ With Rails 8, Propshaft is the default asset pipeline for new applications. With

## Usage

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

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")`).

Expand Down Expand Up @@ -50,9 +50,55 @@ export default class extends Controller {

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.

## Subresource Integrity (SRI)

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.

### Enabling SRI

To enable SRI support, configure the hash algorithm in your Rails application:

```ruby
config.assets.integrity_hash_algorithm = "sha384"
```

Valid hash algorithms include:
- `"sha256"` - SHA-256 (most common)
- `"sha384"` - SHA-384 (recommended for enhanced security)
- `"sha512"` - SHA-512 (strongest)

### Using SRI in your views

Once configured, you can enable SRI by passing the `integrity: true` option to asset helpers:

```erb
<%= stylesheet_link_tag "application", integrity: true %>
<%= javascript_include_tag "application", integrity: true %>
```

This generates HTML with integrity hashes:

```html
<link rel="stylesheet" href="/assets/application-abc123.css"
integrity="sha384-xyz789...">
<script src="/assets/application-def456.js"
integrity="sha384-uvw012..."></script>
```

**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.

### Bulk stylesheet inclusion with SRI

Propshaft extends `stylesheet_link_tag` with special symbols for bulk inclusion:

```erb
<%= stylesheet_link_tag :all, integrity: true %> <!-- All stylesheets -->
<%= stylesheet_link_tag :app, integrity: true %> <!-- Only app/assets stylesheets -->
```

## Improving performance in development

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

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:

Expand Down
9 changes: 8 additions & 1 deletion lib/propshaft/assembly.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "propshaft/manifest"
require "propshaft/load_path"
require "propshaft/resolver/dynamic"
require "propshaft/resolver/static"
Expand All @@ -16,7 +17,13 @@ def initialize(config)
end

def load_path
@load_path ||= Propshaft::LoadPath.new(config.paths, compilers: compilers, version: config.version, file_watcher: config.file_watcher)
@load_path ||= Propshaft::LoadPath.new(
config.paths,
compilers: compilers,
version: config.version,
file_watcher: config.file_watcher,
integrity_hash_algorithm: config.integrity_hash_algorithm
)
end

def resolver
Expand Down
23 changes: 23 additions & 0 deletions lib/propshaft/asset.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "digest/sha1"
require "digest/sha2"
require "action_dispatch/http/mime_type"

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

def compiled_content
@compiled_content ||= load_path.compilers.compile(self)
end

def content(encoding: "ASCII-8BIT")
File.read(path, encoding: encoding, mode: "rb")
end
Expand All @@ -33,6 +38,24 @@ def digest
@digest ||= Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8)
end

def integrity(hash_algorithm:)
# Following the Subresource Integrity spec draft
# https://w3c.github.io/webappsec-subresource-integrity/
# allowing only sha256, sha384, and sha512
bitlen = case hash_algorithm
when "sha256"
256
when "sha384"
384
when "sha512"
512
else
raise(StandardError.new("Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)"))
end

[hash_algorithm, Digest::SHA2.new(bitlen).base64digest(compiled_content)].join("-")
end

def digested_path
if already_digested?
logical_path
Expand Down
139 changes: 133 additions & 6 deletions lib/propshaft/helper.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,102 @@
module Propshaft
# Helper module that provides asset path resolution and integrity support for Rails applications.
#
# This module extends Rails' built-in asset helpers with additional functionality:
# - Subresource Integrity (SRI) support for enhanced security
# - Bulk stylesheet inclusion with :all and :app options
# - Asset path resolution with proper error handling
#
# == Subresource Integrity (SRI) Support
#
# SRI helps protect against malicious modifications of assets by ensuring that
# resources fetched from CDNs or other sources haven't been tampered with.
#
# SRI is automatically enabled in secure contexts (HTTPS or local development)
# when the 'integrity' option is set to true:
#
# <%= stylesheet_link_tag "application", integrity: true %>
# <%= javascript_include_tag "application", integrity: true %>
#
# This will generate integrity hashes and include them in the HTML:
#
# <link rel="stylesheet" href="/assets/application-abc123.css"
# integrity="sha256-xyz789...">
# <script src="/assets/application-def456.js"
# integrity="sha256-uvw012..."></script>
#
# == Bulk Stylesheet Inclusion
#
# The stylesheet_link_tag helper supports special symbols for bulk inclusion:
# - :all - includes all CSS files found in the load path
# - :app - includes only CSS files from app/assets/**/*.css
#
# <%= stylesheet_link_tag :all %> # All stylesheets
# <%= stylesheet_link_tag :app %> # Only app stylesheets
module Helper
# Computes the Subresource Integrity (SRI) hash for the given asset path.
#
# This method generates a cryptographic hash of the asset content that can be used
# to verify the integrity of the resource when it's loaded by the browser.
#
# asset_integrity("application.css")
# # => "sha256-xyz789abcdef..."
def asset_integrity(path, options = {})
path = _path_with_extname(path, options)
Rails.application.assets.resolver.integrity(path)
end

# Resolves the full path for an asset, raising an error if not found.
def compute_asset_path(path, options = {})
Rails.application.assets.resolver.resolve(path) || raise(MissingAssetError.new(path))
end

# Add an option to call `stylesheet_link_tag` with `:all` to include every css file found on the load path
# or `:app` to include css files found in `Rails.root("app/assets/**/*.css")`, which will exclude lib/ and plugins.
# Enhanced +stylesheet_link_tag+ with integrity support and bulk inclusion options.
#
# In addition to the standard Rails functionality, this method supports:
# * Automatic SRI (Subresource Integrity) hash generation in secure contexts
# * Add an option to call +stylesheet_link_tag+ with +:all+ to include every css
# file found on the load path or +:app+ to include css files found in
# <tt>Rails.root("app/assets/**/*.css")</tt>, which will exclude lib/ and plugins.
#
# ==== Options
#
# * <tt>:integrity</tt> - Enable SRI hash generation
#
# ==== Examples
#
# stylesheet_link_tag "application", integrity: true
# # => <link rel="stylesheet" href="/assets/application-abc123.css"
# # integrity="sha256-xyz789...">
#
# stylesheet_link_tag :all # All stylesheets in load path
# stylesheet_link_tag :app # Only app/assets stylesheets
def stylesheet_link_tag(*sources, **options)
case sources.first
when :all
super(*all_stylesheets_paths , **options)
sources = all_stylesheets_paths
when :app
super(*app_stylesheets_paths , **options)
else
super
sources = app_stylesheets_paths
end

_build_asset_tags(sources, options, :stylesheet) { |source, opts| super(source, opts) }
end

# Enhanced +javascript_include_tag+ with automatic SRI (Subresource Integrity) support.
#
# This method extends Rails' built-in +javascript_include_tag+ to automatically
# generate and include integrity hashes when running in secure contexts.
#
# ==== Options
#
# * <tt>:integrity</tt> - Enable SRI hash generation
#
# ==== Examples
#
# javascript_include_tag "application", integrity: true
# # => <script src="/assets/application-abc123.js"
# # integrity="sha256-xyz789..."></script>
def javascript_include_tag(*sources, **options)
_build_asset_tags(sources, options, :javascript) { |source, opts| super(source, opts) }
end

# Returns a sorted and unique array of logical paths for all stylesheets in the load path.
Expand All @@ -26,5 +108,50 @@ def all_stylesheets_paths
def app_stylesheets_paths
Rails.application.assets.load_path.asset_paths_by_glob("#{Rails.root.join("app/assets")}/**/*.css")
end

private
# Core method that builds asset tags with optional integrity support.
#
# This method handles the common logic for both +stylesheet_link_tag+ and
# +javascript_include_tag+, including SRI hash generation and HTML tag creation.
def _build_asset_tags(sources, options, asset_type)
options = options.stringify_keys
integrity = _compute_integrity?(options)

sources.map { |source|
opts = integrity ? options.merge!('integrity' => asset_integrity(source, type: asset_type)) : options
yield(source, opts)
}.join("\n").html_safe
end

# Determines whether integrity hashes should be computed for assets.
#
# Integrity is only computed in secure contexts (HTTPS or local development)
# and when explicitly requested via the +integrity+ option.
def _compute_integrity?(options)
if _secure_subresource_integrity_context?
case options['integrity']
when nil, false, true
options.delete('integrity') == true
end
else
options.delete 'integrity'
false
end
end

# Checks if the current context is secure enough for Subresource Integrity.
#
# SRI is only beneficial in secure contexts. Returns true when:
# * The request is made over HTTPS (SSL), OR
# * The request is local (development environment)
def _secure_subresource_integrity_context?
respond_to?(:request) && self.request && (self.request.local? || self.request.ssl?)
end

# Ensures the asset path includes the appropriate file extension.
def _path_with_extname(path, options)
"#{path}#{compute_asset_extname(path, options)}"
end
end
end
13 changes: 6 additions & 7 deletions lib/propshaft/load_path.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "propshaft/manifest"
require "propshaft/asset"

class Propshaft::LoadPath
Expand All @@ -11,10 +12,10 @@ def execute_if_updated
end
end

attr_reader :paths, :compilers, :version
attr_reader :paths, :compilers, :version, :integrity_hash_algorithm

def initialize(paths = [], compilers:, version: nil, file_watcher: nil)
@paths, @compilers, @version = dedup(paths), compilers, version
def initialize(paths = [], compilers:, version: nil, file_watcher: nil, integrity_hash_algorithm: nil)
@paths, @compilers, @version, @integrity_hash_algorithm = dedup(paths), compilers, version, integrity_hash_algorithm
@file_watcher = file_watcher || NullFileWatcher
end

Expand All @@ -41,10 +42,8 @@ def asset_paths_by_glob(glob)
end

def manifest
Hash.new.tap do |manifest|
assets.each do |asset|
manifest[asset.logical_path.to_s] = asset.digested_path.to_s
end
Propshaft::Manifest.new(integrity_hash_algorithm: integrity_hash_algorithm).tap do |manifest|
assets.each { |asset| manifest.push_asset(asset) }
end
end

Expand Down
Loading