Skip to content

Add support for subresource integrity (#506) #570

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 4 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
## [Unreleased]
Changes since the last non-beta release.


### Added

- Support for subresource integrity. [PR 570](https://github.com/shakacode/shakapacker/pull/570) by [panagiotisplytas](https://github.com/panagiotisplytas)

### Fixed

- Remove duplicate word in comment from generated `shakapacker.yml` config [PR 572](https://github.com/shakacode/shakapacker/pull/572) by [G-Rath](https://github.com/g-rath).
Expand Down
54 changes: 54 additions & 0 deletions docs/subresource_integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Subresource integrity
It's a cryptographic hash that helps browsers check that the served js or css file has not been tampered in any way.

[MDN - Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)

## Important notes
- If you somehow modify the file after the hash was generated, it will automatically be considered as tampered, and the browser will not allow it to be executed.
- Enabling subresource integrity generation, will change the structure of `manifest.json`. Keep that in mind if you utilize this file in any other custom implementation.

Before:
```json
{
"application.js": "/path_to_asset"
}
```

After:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: should we just switch to always generating an object going forward? (either now or in the next major)

while that'd technically make the file a little bigger in all cases, we'll only be talking a few bytes and ultimately SRI should be used whenever possible...

We could do this as a follow up, but I'm thinking if so we could add a new migrate-now kind of option for having the manifest always generated with { src: ... }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Eventually, yes, i think we should have the same kind of output in the manifest.json across all environments (+/- integrity hashes). For consistency, and also to make the implementation & tests of SRI (and other features like that ) a bit simpler.

Having said that, and looking at the webpack-assets-manifest -plugin i am not sure how can we achieve that. Tried playing a bit with customize function in the past, but it went comically bad 😅 .
I may be missing something obvious tho.

I'll be happy to try if you have any suggestion in mind .

```json
{
"application.js": {
"src": "/path_to_asset",
"integrity": "<sha256-hash> <sha384-hash> <sha512-hash>"
}
}
```

## Possible CORS issues
Enabling subresource integrity for an asset, actually enforces CORS checks on that resource too. Which means that
if you haven't set that up properly beforehand, it will probably lead to CORS errors with cached assets.

[MDN - How browsers handle Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity#how_browsers_handle_subresource_integrity)

## Configuration

By default, this setting is disabled, to ensure backwards compatibility, and let developers adapt at their own pace.
This may change in the future, as it is a very nice security feature, and it should be enabled by default.

To enable it, just add this in `shakapacker.yml`
```yml
integrity:
enabled: true
```

For further customization, you can also utilize the options `hash_functions` that control the functions used to generate
integrity hashes. And `cross_origin` that sets the cross-origin loading attribute.

```yml
integrity:
enabled: true
hash_functions: ["sha256", "sha384", "sha512"]
cross_origin: "anonymous" # or "use-credentials"
```

This will utilize under the hood webpack-subresource-integrity plugin and will modify `manifest.json` to include integrity hashes.
10 changes: 10 additions & 0 deletions lib/install/config/shakapacker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ default: &default
# SHAKAPACKER_ASSET_HOST will override both configurations.
# asset_host: custom-path

# Utilizing webpack-subresource-integrity plugin, will generate integrity hashes for all entries in manifest.json
# https://github.com/waysact/webpack-subresource-integrity/tree/main/webpack-subresource-integrity
integrity:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this can be done as a follow-up, but should we consider shorthands such as integrity: false?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure i understand, can you elaborate a bit?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so instead of this:

integrity:
  disabled: false

you'd be able to do

integrity: false

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I can definitely try.
But just by brainstorming, it feels like we'd be adding more checks, for little to no benefit. I may be missing something obvious here.

I'll try to come back to this within a few days, if i'm still stuck on how to do it we can leave it as a follow up

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@G-Rath Something like this ? 🤔

enabled: false
# Which cryptographic function(s) to use, for generating the integrity hash(es). Default sha-384. Other possible values sha256, sha512
hash_functions: ["sha384"]
# Default "anonymous". Other possible value "use-credentials"
# https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity#cross-origin_resource_sharing_and_subresource_integrity
cross_origin: "anonymous"

development:
<<: *default
compile: true
Expand Down
8 changes: 8 additions & 0 deletions lib/shakapacker/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ def asset_host
)
end

def integrity
integrity_configuration = fetch(:integrity)

return integrity_configuration if integrity_configuration.is_a?(Hash)

defaults[:integrity].merge({ enabled: integrity_configuration })
end

private
def data
@data ||= load
Expand Down
44 changes: 40 additions & 4 deletions lib/shakapacker/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ def javascript_pack_tag(*names, defer: true, async: false, **options)
@javascript_pack_tag_loaded = true

capture do
concat javascript_include_tag(*async, **options.dup.tap { |o| o[:async] = true })
render_tags(async, :javascript, **options.dup.tap { |o| o[:async] = true })
concat "\n" if async.any? && deferred.any?
concat javascript_include_tag(*deferred, **options.dup.tap { |o| o[:defer] = true })
render_tags(deferred, :javascript, **options.dup.tap { |o| o[:defer] = true })
concat "\n" if sync.any? && deferred.any?
concat javascript_include_tag(*sync, **options)
render_tags(sync, :javascript, options)
end
end

Expand Down Expand Up @@ -166,7 +166,9 @@ def stylesheet_pack_tag(*names, **options)

@stylesheet_pack_tag_loaded = true

stylesheet_link_tag(*(requested_packs | appended_packs), **options)
capture do
render_tags(requested_packs | appended_packs, :stylesheet, options)
end
end

def append_stylesheet_pack_tag(*names)
Expand Down Expand Up @@ -238,4 +240,38 @@ def resolve_path_to_image(name, **options)
rescue
path_to_asset(current_shakapacker_instance.manifest.lookup!(name), options)
end

def lookup_integrity(source)
(source.respond_to?(:dig) && source.dig("integrity")) || nil
end

def lookup_source(source)
(source.respond_to?(:dig) && source.dig("src")) || source
end

# Handles rendering javascript and stylesheet tags with integrity, if that's enabled.
def render_tags(sources, type, options)
return unless sources.present? || type.present?

sources.each.with_index do |source, index|
tag_source = lookup_source(source)

if current_shakapacker_instance.config.integrity[:enabled]
integrity = lookup_integrity(source)

if integrity.present?
options[:integrity] = integrity
options[:crossorigin] = current_shakapacker_instance.config.integrity[:cross_origin]
end
end

if type == :javascript
concat javascript_include_tag(tag_source, **options)
else
concat stylesheet_link_tag(tag_source, **options)
end

concat "\n" unless index == sources.size - 1
end
end
end
7 changes: 6 additions & 1 deletion lib/shakapacker/manifest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ def data
end

def find(name)
data[name.to_s].presence
return nil unless data[name.to_s].present?

return data[name.to_s] unless data[name.to_s].respond_to?(:dig)

# Try to return src, if that fails, (ex. entrypoints object) return the whole object.
data[name.to_s].dig("src") || data[name.to_s]
end

def full_pack_name(name, pack_type)
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"thenify": "^3.3.1",
"webpack": "5.93.0",
"webpack-assets-manifest": "^5.0.6",
"webpack-subresource-integrity": "^5.1.0",
"webpack-merge": "^5.8.0"
},
"peerDependencies": {
Expand All @@ -60,6 +61,7 @@
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.76.0",
"webpack-assets-manifest": "^5.0.6 || ^6.0.0",
"webpack-subresource-integrity": "^5.1.0",
"webpack-cli": "^4.9.2 || ^5.0.0 || ^6.0.0",
"webpack-dev-server": "^4.9.0 || ^5.0.0",
"webpack-merge": "^5.8.0 || ^6.0.0"
Expand All @@ -70,6 +72,9 @@
},
"@types/webpack": {
"optional": true
},
"webpack-subresource-integrity": {
"optional": true
}
},
"packageManager": "[email protected]",
Expand Down
13 changes: 13 additions & 0 deletions package/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,17 @@ if (config.manifest_path) {
config.manifestPath = resolve(config.outputPath, "manifest.json")
}

if (typeof config.integrity === "boolean") {
config.integrity = {
enabled: config.integrity,
hash_functions: ["sha384"],
cross_origin: "anonymous"
}
} else {
// Ensure no duplicate hash functions exist in the returned config object
config.integrity.hash_functions = [
...new Set(config.integrity.hash_functions)
]
}

module.exports = config
27 changes: 25 additions & 2 deletions package/environments/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ const getPlugins = () => {
writeToDisk: true,
output: config.manifestPath,
entrypointsUseAssets: true,
publicPath: config.publicPathWithoutCDN
publicPath: config.publicPathWithoutCDN,
integrity: config.integrity.enabled,
integrityHashes: config.integrity.hash_functions
})
]

Expand All @@ -105,6 +107,22 @@ const getPlugins = () => {
)
}

if (
moduleExists("webpack-subresource-integrity") &&
config.integrity.enabled
) {
const {
SubresourceIntegrityPlugin
} = require("webpack-subresource-integrity")

plugins.push(
new SubresourceIntegrityPlugin({
hashFuncNames: config.integrity.hash_functions,
enabled: isProduction
})
)
}

return plugins
}

Expand All @@ -121,7 +139,12 @@ module.exports = {
// https://webpack.js.org/configuration/output/#outputhotupdatechunkfilename
hotUpdateChunkFilename: "js/[id].[fullhash].hot-update.js",
path: config.outputPath,
publicPath: config.publicPath
publicPath: config.publicPath,

// This is required for SRI to work.
crossOriginLoading: config.integrity.enabled
? config.integrity.cross_origin
: false
Comment on lines +144 to +147
Copy link

@coderabbitai coderabbitai bot May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Config key mismatch will disable crossOriginLoading at runtime

crossOriginLoading is set from config.integrity.cross_origin, but the public docs (and the sample in docs/subresource_integrity.md) use cross_origin_loading.
Unless both keys are declared in configuration.rb, this will resolve to undefined, causing Webpack to emit crossOriginLoading: false and breaking SRI in production.

-    crossOriginLoading: config.integrity.enabled
-      ? config.integrity.cross_origin
+    crossOriginLoading: config.integrity.enabled
+      ? config.integrity.cross_origin_loading
       : false

Please align the key name here (or in the docs/config) to avoid silent mis-configuration.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// This is required for SRI to work.
crossOriginLoading: config.integrity.enabled
? config.integrity.cross_origin
: false
// This is required for SRI to work.
crossOriginLoading: config.integrity.enabled
? config.integrity.cross_origin_loading
: false

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was named intentionally like that to cover both crossOriginLoading of the plugin and crossorigin html script tag attribute. @G-Rath any suggestions here? Shall i ignore it?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

},
entry: getEntryObject(),
resolve: {
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"typescript": "^4.7.3",
"webpack": "^5.76.0",
"webpack-assets-manifest": "^5.1.0",
"webpack-subresource-integrity": "^5.1.0",
"webpack-cli": "^4.9.2",
"webpack-merge": "^5.8.0",
"webpack-sources": "^3.2.3"
Expand Down
12 changes: 12 additions & 0 deletions spec/dummy/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4325,6 +4325,11 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"

typed-assert@^1.0.8:
version "1.0.9"
resolved "https://registry.yarnpkg.com/typed-assert/-/typed-assert-1.0.9.tgz#8af9d4f93432c4970ec717e3006f33f135b06213"
integrity sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==

typescript@^4.7.3:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
Expand Down Expand Up @@ -4518,6 +4523,13 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==

webpack-subresource-integrity@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz#8b7606b033c6ccac14e684267cb7fb1f5c2a132a"
integrity sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==
dependencies:
typed-assert "^1.0.8"

webpack@^5.76.0:
version "5.99.6"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.6.tgz#0d6ba7ce1d3609c977f193d2634d54e5cf36379d"
Expand Down
49 changes: 48 additions & 1 deletion spec/shakapacker/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,54 @@
is_expected.to be true
end
end

describe "#integrity" do
it "contains the key :enabled" do
expect(config.integrity).to have_key(:enabled)
end

it "contains the key :hash_functions" do
expect(config.integrity).to have_key(:hash_functions)
end

it "contains the key :cross_origin" do
expect(config.integrity).to have_key(:cross_origin)
end

it "is by default disabled" do
expect(config.integrity[:enabled]).to be false
end

it "returns default cross_origin configuration" do
expect(config.integrity[:cross_origin]).to eq "anonymous"
end

it "returns default hash_functions" do
expect(config.integrity[:hash_functions]).to eq ["sha384"]
end
end
end

context "with shakapacker config file containing integrity" do
let(:config) do
Shakapacker::Configuration.new(
root_path: ROOT_PATH,
config_path: Pathname.new(File.expand_path("./test_app/config/shakapacker_integrity.yml", __dir__)),
env: "production"
)
end

it "has integrity enabled" do
expect(config.integrity[:enabled]).to be true
end

it "has all hash functions set" do
expect(config.integrity[:hash_functions]).to eq ["sha256", "sha384", "sha512"]
end

it "has cross_origin set to use-credentials" do
expect(config.integrity[:cross_origin]).to eq "use-credentials"
end
end

context "with shakapacker config file containing public_output_path entry" do
Expand Down Expand Up @@ -373,5 +421,4 @@
end
end
end

end
Loading