Skip to content

Commit 0ae9408

Browse files
Add support for subresource integrity (#506) (#570)
* Add support for subresource integrity (#506) * Add tests for subresource integrity (#506) * fixup! Add tests for subresource integrity (#506)
1 parent 9b86cc6 commit 0ae9408

20 files changed

Lines changed: 1171 additions & 300 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
## [Unreleased]
99
Changes since the last non-beta release.
1010

11+
12+
### Added
13+
14+
- Support for subresource integrity. [PR 570](https://github.com/shakacode/shakapacker/pull/570) by [panagiotisplytas](https://github.com/panagiotisplytas)
15+
1116
### Fixed
1217

1318
- Install the latest major version of peer dependencies [PR 576](https://github.com/shakacode/shakapacker/pull/576) by [G-Rath](https://github.com/g-rath).

docs/subresource_integrity.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Subresource integrity
2+
It's a cryptographic hash that helps browsers check that the served js or css file has not been tampered in any way.
3+
4+
[MDN - Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)
5+
6+
## Important notes
7+
- 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.
8+
- 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.
9+
10+
Before:
11+
```json
12+
{
13+
"application.js": "/path_to_asset"
14+
}
15+
```
16+
17+
After:
18+
```json
19+
{
20+
"application.js": {
21+
"src": "/path_to_asset",
22+
"integrity": "<sha256-hash> <sha384-hash> <sha512-hash>"
23+
}
24+
}
25+
```
26+
27+
## Possible CORS issues
28+
Enabling subresource integrity for an asset, actually enforces CORS checks on that resource too. Which means that
29+
if you haven't set that up properly beforehand, it will probably lead to CORS errors with cached assets.
30+
31+
[MDN - How browsers handle Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity#how_browsers_handle_subresource_integrity)
32+
33+
## Configuration
34+
35+
By default, this setting is disabled, to ensure backwards compatibility, and let developers adapt at their own pace.
36+
This may change in the future, as it is a very nice security feature, and it should be enabled by default.
37+
38+
To enable it, just add this in `shakapacker.yml`
39+
```yml
40+
integrity:
41+
enabled: true
42+
```
43+
44+
For further customization, you can also utilize the options `hash_functions` that control the functions used to generate
45+
integrity hashes. And `cross_origin` that sets the cross-origin loading attribute.
46+
47+
```yml
48+
integrity:
49+
enabled: true
50+
hash_functions: ["sha256", "sha384", "sha512"]
51+
cross_origin: "anonymous" # or "use-credentials"
52+
```
53+
54+
This will utilize under the hood webpack-subresource-integrity plugin and will modify `manifest.json` to include integrity hashes.

lib/install/config/shakapacker.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ default: &default
5555
# SHAKAPACKER_ASSET_HOST will override both configurations.
5656
# asset_host: custom-path
5757

58+
# Utilizing webpack-subresource-integrity plugin, will generate integrity hashes for all entries in manifest.json
59+
# https://github.com/waysact/webpack-subresource-integrity/tree/main/webpack-subresource-integrity
60+
integrity:
61+
enabled: false
62+
# Which cryptographic function(s) to use, for generating the integrity hash(es). Default sha-384. Other possible values sha256, sha512
63+
hash_functions: ["sha384"]
64+
# Default "anonymous". Other possible value "use-credentials"
65+
# https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity#cross-origin_resource_sharing_and_subresource_integrity
66+
cross_origin: "anonymous"
67+
5868
development:
5969
<<: *default
6070
compile: true

lib/shakapacker/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ def asset_host
9999
)
100100
end
101101

102+
def integrity
103+
fetch(:integrity)
104+
end
105+
102106
private
103107
def data
104108
@data ||= load

lib/shakapacker/helper.rb

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,11 @@ def javascript_pack_tag(*names, defer: true, async: false, **options)
109109
@javascript_pack_tag_loaded = true
110110

111111
capture do
112-
concat javascript_include_tag(*async, **options.dup.tap { |o| o[:async] = true })
112+
render_tags(async, :javascript, **options.dup.tap { |o| o[:async] = true })
113113
concat "\n" if async.any? && deferred.any?
114-
concat javascript_include_tag(*deferred, **options.dup.tap { |o| o[:defer] = true })
114+
render_tags(deferred, :javascript, **options.dup.tap { |o| o[:defer] = true })
115115
concat "\n" if sync.any? && deferred.any?
116-
concat javascript_include_tag(*sync, **options)
116+
render_tags(sync, :javascript, options)
117117
end
118118
end
119119

@@ -166,7 +166,9 @@ def stylesheet_pack_tag(*names, **options)
166166

167167
@stylesheet_pack_tag_loaded = true
168168

169-
stylesheet_link_tag(*(requested_packs | appended_packs), **options)
169+
capture do
170+
render_tags(requested_packs | appended_packs, :stylesheet, options)
171+
end
170172
end
171173

172174
def append_stylesheet_pack_tag(*names)
@@ -238,4 +240,38 @@ def resolve_path_to_image(name, **options)
238240
rescue
239241
path_to_asset(current_shakapacker_instance.manifest.lookup!(name), options)
240242
end
243+
244+
def lookup_integrity(source)
245+
(source.respond_to?(:dig) && source.dig("integrity")) || nil
246+
end
247+
248+
def lookup_source(source)
249+
(source.respond_to?(:dig) && source.dig("src")) || source
250+
end
251+
252+
# Handles rendering javascript and stylesheet tags with integrity, if that's enabled.
253+
def render_tags(sources, type, options)
254+
return unless sources.present? || type.present?
255+
256+
sources.each.with_index do |source, index|
257+
tag_source = lookup_source(source)
258+
259+
if current_shakapacker_instance.config.integrity[:enabled]
260+
integrity = lookup_integrity(source)
261+
262+
if integrity.present?
263+
options[:integrity] = integrity
264+
options[:crossorigin] = current_shakapacker_instance.config.integrity[:cross_origin]
265+
end
266+
end
267+
268+
if type == :javascript
269+
concat javascript_include_tag(tag_source, **options)
270+
else
271+
concat stylesheet_link_tag(tag_source, **options)
272+
end
273+
274+
concat "\n" unless index == sources.size - 1
275+
end
276+
end
241277
end

lib/shakapacker/manifest.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ def data
6767
end
6868

6969
def find(name)
70-
data[name.to_s].presence
70+
return nil unless data[name.to_s].present?
71+
72+
return data[name.to_s] unless data[name.to_s].respond_to?(:dig)
73+
74+
# Try to return src, if that fails, (ex. entrypoints object) return the whole object.
75+
data[name.to_s].dig("src") || data[name.to_s]
7176
end
7277

7378
def full_pack_name(name, pack_type)

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"thenify": "^3.3.1",
4747
"webpack": "5.93.0",
4848
"webpack-assets-manifest": "^5.0.6",
49+
"webpack-subresource-integrity": "^5.1.0",
4950
"webpack-merge": "^5.8.0"
5051
},
5152
"peerDependencies": {
@@ -60,6 +61,7 @@
6061
"terser-webpack-plugin": "^5.3.1",
6162
"webpack": "^5.76.0",
6263
"webpack-assets-manifest": "^5.0.6 || ^6.0.0",
64+
"webpack-subresource-integrity": "^5.1.0",
6365
"webpack-cli": "^4.9.2 || ^5.0.0 || ^6.0.0",
6466
"webpack-dev-server": "^4.9.0 || ^5.0.0",
6567
"webpack-merge": "^5.8.0 || ^6.0.0"
@@ -70,6 +72,9 @@
7072
},
7173
"@types/webpack": {
7274
"optional": true
75+
},
76+
"webpack-subresource-integrity": {
77+
"optional": true
7378
}
7479
},
7580
"packageManager": "[email protected]",

package/config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,7 @@ if (config.manifest_path) {
5050
} else {
5151
config.manifestPath = resolve(config.outputPath, "manifest.json")
5252
}
53+
// Ensure no duplicate hash functions exist in the returned config object
54+
config.integrity.hash_functions = [...new Set(config.integrity.hash_functions)]
5355

5456
module.exports = config

package/environments/base.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ const getPlugins = () => {
8686
writeToDisk: true,
8787
output: config.manifestPath,
8888
entrypointsUseAssets: true,
89-
publicPath: config.publicPathWithoutCDN
89+
publicPath: config.publicPathWithoutCDN,
90+
integrity: config.integrity.enabled,
91+
integrityHashes: config.integrity.hash_functions
9092
})
9193
]
9294

@@ -105,6 +107,22 @@ const getPlugins = () => {
105107
)
106108
}
107109

110+
if (
111+
moduleExists("webpack-subresource-integrity") &&
112+
config.integrity.enabled
113+
) {
114+
const {
115+
SubresourceIntegrityPlugin
116+
} = require("webpack-subresource-integrity")
117+
118+
plugins.push(
119+
new SubresourceIntegrityPlugin({
120+
hashFuncNames: config.integrity.hash_functions,
121+
enabled: isProduction
122+
})
123+
)
124+
}
125+
108126
return plugins
109127
}
110128

@@ -121,7 +139,12 @@ module.exports = {
121139
// https://webpack.js.org/configuration/output/#outputhotupdatechunkfilename
122140
hotUpdateChunkFilename: "js/[id].[fullhash].hot-update.js",
123141
path: config.outputPath,
124-
publicPath: config.publicPath
142+
publicPath: config.publicPath,
143+
144+
// This is required for SRI to work.
145+
crossOriginLoading: config.integrity.enabled
146+
? config.integrity.cross_origin
147+
: false
125148
},
126149
entry: getEntryObject(),
127150
resolve: {

spec/dummy/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"typescript": "^4.7.3",
3131
"webpack": "^5.76.0",
3232
"webpack-assets-manifest": "^5.1.0",
33+
"webpack-subresource-integrity": "^5.1.0",
3334
"webpack-cli": "^4.9.2",
3435
"webpack-merge": "^5.8.0",
3536
"webpack-sources": "^3.2.3"

0 commit comments

Comments
 (0)