Skip to content

Add Subresource Integrity value to manifest #238

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 3 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
10 changes: 9 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,14 @@ 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
19 changes: 19 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 Down Expand Up @@ -33,6 +34,24 @@ def digest
@digest ||= Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8)
end

# Following the Subresource Integrity spec draft
# https://w3c.github.io/webappsec-subresource-integrity/
# allowing only sha256, sha384, and sha512
def integrity(hash_algorithm:)
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(content)].join("-")
end

def digested_path
if already_digested?
logical_path
Expand Down
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:).tap do |manifest|
assets.each { |asset| manifest.push_asset(asset) }
end
end

Expand Down
75 changes: 75 additions & 0 deletions lib/propshaft/manifest.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module Propshaft
class Manifest
class ManifestEntry
attr_reader :logical_path, :digested_path, :integrity
def initialize(logical_path:, digested_path:, integrity:)
@logical_path = logical_path
@digested_path = digested_path
@integrity = integrity
end

def to_h
{ digested_path:, integrity: }
end
end

def initialize(integrity_hash_algorithm: nil)
@integrity_hash_algorithm = integrity_hash_algorithm
@entries = {}
end

def push_asset(asset)
entry = ManifestEntry.new(
logical_path: asset.logical_path.to_s,
digested_path: asset.digested_path.to_s,
integrity: @integrity_hash_algorithm && asset.integrity(hash_algorithm: @integrity_hash_algorithm)
)

push(entry)
end

def push(entry)
@entries[entry.logical_path] = entry
end

def <<(asset)
push(asset)
end

def [](logical_path)
@entries[logical_path]
end

def to_json
Hash.new.tap do |serialized_manifest|
@entries.values.each do |manifest_entry|
serialized_manifest[manifest_entry.logical_path] = manifest_entry.to_h
end
end.to_json
end

class << self
def from_path(manifest_path)
Manifest.new.tap do |manifest|
JSON.parse(manifest_path.read, symbolize_names: false).tap do |serialized_manifest|
serialized_manifest.each_pair do |key, value|
# Compatibility mode to be able to
# read the old "simple manifest" format
digested_path, integrity = if value.is_a?(String)
[value, nil]
else
[value["digested_path"], value["integrity"]]
end

entry = ManifestEntry.new(
logical_path: key, digested_path:, integrity:
)

manifest.push(entry)
end
end
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/propshaft/resolver/dynamic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ def resolve(logical_path)
end
end

def integrity(logical_path)
hash_algorithm = load_path.integrity_hash_algorithm
if (asset = load_path.find(logical_path)) && hash_algorithm
asset.integrity(hash_algorithm:)
end
end

def read(logical_path, options = {})
if asset = load_path.find(logical_path)
asset.content(**options)
Expand Down
20 changes: 16 additions & 4 deletions lib/propshaft/resolver/static.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,32 @@ def initialize(manifest_path:, prefix:)
end

def resolve(logical_path)
if asset_path = parsed_manifest[logical_path]
if asset_path = digested_path(logical_path)
File.join prefix, asset_path
end
end

def integrity(logical_path)
entry = manifest[logical_path]

entry&.integrity
end

def read(logical_path, encoding: "ASCII-8BIT")
if asset_path = parsed_manifest[logical_path]
if asset_path = digested_path(logical_path)
File.read(manifest_path.dirname.join(asset_path), encoding: encoding)
end
end

private
def parsed_manifest
@parsed_manifest ||= JSON.parse(manifest_path.read, symbolize_names: false)
def manifest
@manifest ||= Propshaft::Manifest.from_path(manifest_path)
end

def digested_path(logical_path)
entry = manifest[logical_path]

entry&.digested_path
end
end
end
1 change: 1 addition & 0 deletions test/fixtures/output/.extensible_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"one.txt": {"digested_path": "one-f2e1ec14.txt","integrity": "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe"}}
26 changes: 26 additions & 0 deletions test/propshaft/assembly_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,30 @@ class Propshaft::AssemblyTest < ActiveSupport::TestCase

assert assembly.processor.is_a?(Propshaft::Processor)
end

class Propshaft::AssemblyTest::WithExtensibleManifest < ActiveSupport::TestCase
test "uses static resolver when manifest is present" do
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
config.output_path = Pathname.new("#{__dir__}/../fixtures/output")
config.manifest_path = config.output_path.join(".extensible_manifest.json")
config.prefix = "/assets"

config.integrity_hash_algorithm = "sha384"
})

assert assembly.resolver.is_a?(Propshaft::Resolver::Static)
end

test "uses dynamic resolver when manifest is missing" do
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
config.output_path = Pathname.new("#{__dir__}/../fixtures/assets")
config.manifest_path = config.output_path.join(".extensible_manifest.json")
config.prefix = "/assets"

config.integrity_hash_algorithm = "sha384"
})

assert assembly.resolver.is_a?(Propshaft::Resolver::Dynamic)
end
end
end
17 changes: 17 additions & 0 deletions test/propshaft/asset_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ class Propshaft::AssetTest < ActiveSupport::TestCase
find_asset("file-is-a-sourcemap.js.map").digested_path.to_s
end

test "integrity" do
assert_equal "sha256-+C/K/0dPvIdSC8rl/NDS8zqPp08R0VH+hKMM4D8tNJs=",
find_asset("one.txt").integrity(hash_algorithm: "sha256").to_s

assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe",
find_asset("one.txt").integrity(hash_algorithm: "sha384").to_s

assert_equal "sha512-wzPP7om24750PjHXRlgiDOhILPd4V2AbLRxomBudQaTDI1eYZkM5j8pSH/ylSSUxiGqXR3F6lgVCbsmXkqKrEg==",
find_asset("one.txt").integrity(hash_algorithm: "sha512").to_s

exception = assert_raises StandardError do
find_asset("one.txt").integrity(hash_algorithm: "md5")
end

assert_equal "Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)", exception.message
end

test "value object equality" do
assert_equal find_asset("one.txt"), find_asset("one.txt")
end
Expand Down
8 changes: 4 additions & 4 deletions test/propshaft/load_path_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,16 @@ class Propshaft::LoadPathTest < ActiveSupport::TestCase

test "manifest" do
@load_path.manifest.tap do |manifest|
assert_equal "one-f2e1ec14.txt", manifest["one.txt"]
assert_equal "nested/three-6c2b86a0.txt", manifest["nested/three.txt"]
assert_equal "one-f2e1ec14.txt", manifest["one.txt"].digested_path.to_s
assert_equal "nested/three-6c2b86a0.txt", manifest["nested/three.txt"].digested_path.to_s
end
end

test "manifest with version" do
@load_path = Propshaft::LoadPath.new(@load_path.paths, version: "1", compilers: Propshaft::Compilers.new(nil))
@load_path.manifest.tap do |manifest|
assert_equal "one-c9373b68.txt", manifest["one.txt"]
assert_equal "nested/three-a41a5d38.txt", manifest["nested/three.txt"]
assert_equal "one-c9373b68.txt", manifest["one.txt"].digested_path.to_s
assert_equal "nested/three-a41a5d38.txt", manifest["nested/three.txt"].digested_path.to_s
end
end

Expand Down
50 changes: 50 additions & 0 deletions test/propshaft/manifest_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require "test_helper"
require "propshaft/manifest"

class Propshaft::ManifestTest < ActiveSupport::TestCase
test "serializes to the extensible manifest format with integrity hash value" do
manifest = create_manifest("sha384")
parsed_manifest = JSON.parse(manifest.to_json)

manifest_entry = parsed_manifest["one.txt"]
assert_equal "one-f2e1ec14.txt", manifest_entry["digested_path"]
assert_equal "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe", manifest_entry["integrity"]

manifest_entry = parsed_manifest["another.css"]
assert_equal "another-c464b1ee.css", manifest_entry["digested_path"]
assert_equal "sha384-RZLbo+FZ8rnE9ct6dNqDcgIYo7DBk/GaB4nCMnNsj6HWp0ePV8q8qky9Qemdpuwl", manifest_entry["integrity"]
end

test "serializes to the extensible manifest format without integrity hash algorithm" do
manifest = create_manifest
parsed_manifest = JSON.parse(manifest.to_json)

manifest_entry = parsed_manifest["one.txt"]
assert_equal "one-f2e1ec14.txt", manifest_entry["digested_path"]
assert_nil manifest_entry["integrity"]

manifest_entry = parsed_manifest["another.css"]
assert_equal "another-c464b1ee.css", manifest_entry["digested_path"]
assert_nil manifest_entry["integrity"]
end

private
def create_manifest(integrity_hash_algorithm = nil)
Propshaft::Manifest.new(integrity_hash_algorithm:).tap do |manifest|
manifest.push_asset(find_asset("one.txt"))
manifest.push_asset(find_asset("another.css"))
end
end

def find_asset(logical_path)
root_path = Pathname.new("#{__dir__}/../fixtures/assets/first_path")
path = root_path.join(logical_path)

assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
config.paths = [ root_path ]
config.compilers = [[ "text/css", Propshaft::Compiler::CssAssetUrls ]]
})

Propshaft::Asset.new(path, logical_path: logical_path, load_path: assembly.load_path)
end
end
1 change: 1 addition & 0 deletions test/propshaft/output_path_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Propshaft::OutputPathTest < ActiveSupport::TestCase
setup do
@manifest = {
".manifest.json": ".manifest.json",
".extensible_manifest.json": ".extensible_manifest.json",
"one.txt": "one-f2e1ec14.txt",
"one.txt.map": "one-f2e1ec15.txt.map"
}.stringify_keys
Expand Down
31 changes: 30 additions & 1 deletion test/propshaft/processor_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,36 @@ class Propshaft::ProcessorTest < ActiveSupport::TestCase
test "manifest is written" do
processed do |processor|
assert_equal "one-f2e1ec14.txt",
JSON.parse(processor.output_path.join(".manifest.json").read)["one.txt"]
JSON.parse(processor.output_path.join(".manifest.json").read)["one.txt"]["digested_path"]
end
end


test "new manifest version is written" do
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
config.output_path = Pathname.new("#{__dir__}/../fixtures/output")
config.prefix = "/assets"
config.paths = [
Pathname.new("#{__dir__}/../fixtures/assets/first_path"),
Pathname.new("#{__dir__}/../fixtures/assets/second_path")
]

config.integrity_hash_algorithm = "sha384"
})

Dir.mktmpdir do |output_path|
output_path = Pathname.new(output_path)
processor = Propshaft::Processor.new(
load_path: assembly.load_path, output_path: output_path,
compilers: assembly.compilers, manifest_path: output_path.join(".manifest.json")
)

processor.process

manifest_entry = JSON.parse(processor.output_path.join(".manifest.json").read)["one.txt"]

assert_equal manifest_entry["digested_path"], "one-f2e1ec14.txt"
assert_equal manifest_entry["integrity"], "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe"
end
end

Expand Down
Loading