Skip to content

Commit 2d5f54a

Browse files
Add support for subresource integrity (#506)
1 parent 9056c8f commit 2d5f54a

File tree

9 files changed

+253
-7
lines changed

9 files changed

+253
-7
lines changed

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 hash that helps browsers check that the served js or css file has not been tampered in any way.
3+
4+
More: 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+
More: 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_loading: "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] || "anonymous"
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: 2 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"

package/environments/base.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const rules = require("../rules")
1111
const config = require("../config")
1212
const { isProduction } = require("../env")
1313
const { moduleExists } = require("../utils/helpers")
14+
const {
15+
isIntegrityEnabled,
16+
hashFunctions,
17+
crossOrigin
18+
} = require("../utils/integrityHelpers")
1419

1520
const getFilesInDirectory = (dir, includeNested) => {
1621
if (!existsSync(dir)) {
@@ -86,7 +91,9 @@ const getPlugins = () => {
8691
writeToDisk: true,
8792
output: config.manifestPath,
8893
entrypointsUseAssets: true,
89-
publicPath: config.publicPathWithoutCDN
94+
publicPath: config.publicPathWithoutCDN,
95+
integrity: isIntegrityEnabled(),
96+
integrityHashes: hashFunctions()
9097
})
9198
]
9299

@@ -105,6 +112,19 @@ const getPlugins = () => {
105112
)
106113
}
107114

115+
if (moduleExists("webpack-subresource-integrity") && isIntegrityEnabled()) {
116+
const {
117+
SubresourceIntegrityPlugin
118+
} = require("webpack-subresource-integrity")
119+
120+
plugins.push(
121+
new SubresourceIntegrityPlugin({
122+
hashFuncNames: hashFunctions(),
123+
enabled: isProduction
124+
})
125+
)
126+
}
127+
108128
return plugins
109129
}
110130

@@ -121,7 +141,10 @@ module.exports = {
121141
// https://webpack.js.org/configuration/output/#outputhotupdatechunkfilename
122142
hotUpdateChunkFilename: "js/[id].[fullhash].hot-update.js",
123143
path: config.outputPath,
124-
publicPath: config.publicPath
144+
publicPath: config.publicPath,
145+
146+
// This is required for SRI to work.
147+
crossOriginLoading: isIntegrityEnabled() ? crossOrigin() : false
125148
},
126149
entry: getEntryObject(),
127150
resolve: {

package/utils/integrityHelpers.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
const { existsSync, readFileSync } = require("fs")
2+
const { load } = require("js-yaml")
3+
const configPath = require("./configPath")
4+
const { railsEnv } = require("../env")
5+
6+
/**
7+
* Loads and retrieves the environment-specific configuration object.
8+
*
9+
* @returns {object | null} The configuration object for the current railsEnv,
10+
* or null if the file/key doesn't exist or an error occurs.
11+
*/
12+
const loadAndCacheConfig = () => {
13+
if (!existsSync(configPath)) {
14+
/* eslint no-console:0 */
15+
console.warn(
16+
`Warning: Configuration file not found at ${configPath}. Using default integrity settings.`
17+
)
18+
return null
19+
}
20+
21+
try {
22+
const fileContent = readFileSync(configPath, "utf8")
23+
const appYmlObject = load(fileContent)
24+
25+
const envConfig = appYmlObject[railsEnv]
26+
27+
if (!envConfig) {
28+
console.warn(
29+
`Warning: Environment key "${railsEnv}" not found in ${configPath}. Using default integrity settings.`
30+
)
31+
return null
32+
}
33+
34+
return envConfig
35+
} catch (error) {
36+
console.error(
37+
`Error reading or parsing config file ${configPath}: ${error}. Using default integrity settings.`
38+
)
39+
return null
40+
}
41+
}
42+
43+
const envAppConfig = loadAndCacheConfig()
44+
45+
/**
46+
* Checks if integrity is enabled in the configuration.
47+
* Defaults to false if config is missing, the key is not found, or the setting is not specified.
48+
* @returns {boolean} True if integrity is enabled, false otherwise.
49+
*/
50+
const isIntegrityEnabled = () => {
51+
if (
52+
envAppConfig &&
53+
envAppConfig.integrity &&
54+
typeof envAppConfig.integrity.enabled !== "undefined"
55+
) {
56+
return !!envAppConfig.integrity.enabled
57+
}
58+
59+
return false
60+
}
61+
62+
/**
63+
* Gets the list of hash functions specified in the configuration.
64+
* Defaults to ['sha384'] if config is missing, the key is not found, or the setting is not specified.
65+
* @returns {string[]} An array of hash function names.
66+
*/
67+
const hashFunctions = () => {
68+
if (
69+
envAppConfig &&
70+
envAppConfig.integrity &&
71+
envAppConfig.integrity.hash_functions != null
72+
) {
73+
return envAppConfig.integrity.hash_functions
74+
}
75+
76+
return ["sha384"]
77+
}
78+
79+
/**
80+
* Gets the cross-origin attribute value specified in the configuration.
81+
* Defaults to 'anonymous' if config is missing, the key is not found, or the setting is not specified.
82+
* @returns {string} The cross-origin attribute value.
83+
*/
84+
const crossOrigin = () => {
85+
if (
86+
envAppConfig &&
87+
envAppConfig.integrity &&
88+
envAppConfig.integrity.cross_origin != null
89+
) {
90+
return envAppConfig.integrity.cross_origin
91+
}
92+
93+
return "anonymous"
94+
}
95+
96+
module.exports = {
97+
isIntegrityEnabled,
98+
hashFunctions,
99+
crossOrigin
100+
}

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4457,6 +4457,11 @@ typed-array-length@^1.0.7:
44574457
possible-typed-array-names "^1.0.0"
44584458
reflect.getprototypeof "^1.0.6"
44594459

4460+
typed-assert@^1.0.8:
4461+
version "1.0.9"
4462+
resolved "https://registry.yarnpkg.com/typed-assert/-/typed-assert-1.0.9.tgz#8af9d4f93432c4970ec717e3006f33f135b06213"
4463+
integrity sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==
4464+
44604465
unbox-primitive@^1.1.0:
44614466
version "1.1.0"
44624467
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2"
@@ -4551,6 +4556,13 @@ webpack-sources@^3.2.3:
45514556
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
45524557
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
45534558

4559+
webpack-subresource-integrity@^5.1.0:
4560+
version "5.1.0"
4561+
resolved "https://registry.yarnpkg.com/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz#8b7606b033c6ccac14e684267cb7fb1f5c2a132a"
4562+
integrity sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==
4563+
dependencies:
4564+
typed-assert "^1.0.8"
4565+
45544566
45554567
version "5.93.0"
45564568
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5"

0 commit comments

Comments
 (0)