Skip to content

Commit 8ab3959

Browse files
committed
Add an enableIntegrityHashes() method to the public API
1 parent f35841a commit 8ab3959

File tree

6 files changed

+235
-31
lines changed

6 files changed

+235
-31
lines changed

index.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,33 @@ class Encore {
10981098
return this;
10991099
}
11001100

1101+
/**
1102+
* If enabled, add integrity hashes to the entrypoints.json
1103+
* file for all the files it references.
1104+
*
1105+
* These hashes can then be used, for instance, in the "integrity"
1106+
* attributes of <script> and <style> tags to enable subresource-
1107+
* integrity checks in the browser.
1108+
*
1109+
* https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
1110+
*
1111+
* For example:
1112+
*
1113+
* Encore.enableIntegrityHashes(
1114+
* Encore.isProduction(),
1115+
* 'sha384'
1116+
* );
1117+
*
1118+
* @param {bool} enabled
1119+
* @param {string} algorithm
1120+
* @returns {Encore}
1121+
*/
1122+
enableIntegrityHashes(enabled = true, algorithm = 'sha384') {
1123+
webpackConfig.enableIntegrityHashes(enabled, algorithm);
1124+
1125+
return this;
1126+
}
1127+
11011128
/**
11021129
* Is this currently a "production" build?
11031130
*

lib/WebpackConfig.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
const path = require('path');
1313
const fs = require('fs');
14+
const crypto = require('crypto');
1415
const logger = require('./logger');
1516

1617
/**
@@ -48,6 +49,7 @@ class WebpackConfig {
4849
this.configuredFilenames = {};
4950
this.aliases = {};
5051
this.externals = [];
52+
this.integrityAlgorithm = null;
5153

5254
// Features/Loaders flags
5355
this.useVersioning = false;
@@ -682,6 +684,19 @@ class WebpackConfig {
682684
});
683685
}
684686

687+
enableIntegrityHashes(enabled = true, algorithm = 'sha384') {
688+
if (typeof algorithm !== 'string') {
689+
throw new Error('Argument 2 to enableIntegrityHashes() must be a string.');
690+
}
691+
692+
const availableHashes = crypto.getHashes();
693+
if (!availableHashes.includes(algorithm)) {
694+
throw new Error(`Invalid hash algorithm "${algorithm}" passed to enableIntegrityHashes().`);
695+
}
696+
697+
this.integrityAlgorithm = enabled ? algorithm : null;
698+
}
699+
685700
useDevServer() {
686701
return this.runtimeConfig.useDevServer;
687702
}

lib/plugins/entry-files-manifest.js

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,66 @@ const PluginPriorities = require('./plugin-priorities');
1313
const sharedEntryTmpName = require('../utils/sharedEntryTmpName');
1414
const copyEntryTmpName = require('../utils/copyEntryTmpName');
1515
const AssetsPlugin = require('assets-webpack-plugin');
16+
const fs = require('fs');
17+
const path = require('path');
18+
const crypto = require('crypto');
1619

17-
function processOutput(assets) {
18-
for (const entry of [copyEntryTmpName, sharedEntryTmpName]) {
19-
delete assets[entry];
20-
}
21-
22-
// with --watch or dev-server, subsequent calls will include
23-
// the original assets (so, assets.entrypoints) + the new
24-
// assets (which will have their original structure). We
25-
// delete the entrypoints key, and then process the new assets
26-
// like normal below
27-
delete assets.entrypoints;
28-
29-
// This will iterate over all the entry points and convert the
30-
// one file entries into an array of one entry since that was how the entry point file was before this change.
31-
for (const asset in assets) {
32-
for (const fileType in assets[asset]) {
33-
if (!Array.isArray(assets[asset][fileType])) {
34-
assets[asset][fileType] = [assets[asset][fileType]];
20+
function processOutput(webpackConfig) {
21+
return (assets) => {
22+
for (const entry of [copyEntryTmpName, sharedEntryTmpName]) {
23+
delete assets[entry];
24+
}
25+
26+
// with --watch or dev-server, subsequent calls will include
27+
// the original assets (so, assets.entrypoints) + the new
28+
// assets (which will have their original structure). We
29+
// delete the entrypoints key, and then process the new assets
30+
// like normal below
31+
delete assets.entrypoints;
32+
33+
// This will iterate over all the entry points and convert the
34+
// one file entries into an array of one entry since that was how the entry point file was before this change.
35+
const integrity = {};
36+
const integrityAlgorithm = webpackConfig.integrityAlgorithm;
37+
const publicPath = webpackConfig.getRealPublicPath();
38+
39+
if (integrityAlgorithm) {
40+
integrity.algorithm = integrityAlgorithm;
41+
integrity.hashes = {};
42+
}
43+
44+
for (const asset in assets) {
45+
for (const fileType in assets[asset]) {
46+
if (!Array.isArray(assets[asset][fileType])) {
47+
assets[asset][fileType] = [assets[asset][fileType]];
48+
}
49+
50+
if (webpackConfig.integrityAlgorithm) {
51+
for (const file of assets[asset][fileType]) {
52+
if (!(file in integrity.hashes)) {
53+
const filePath = path.resolve(
54+
webpackConfig.outputPath,
55+
file.replace(publicPath, '')
56+
);
57+
58+
if (fs.existsSync(filePath)) {
59+
const hash = crypto.createHash(webpackConfig.integrityAlgorithm);
60+
const fileContent = fs.readFileSync(filePath, 'utf8');
61+
hash.update(fileContent, 'utf8');
62+
63+
integrity.hashes[file] = hash.digest('base64');
64+
}
65+
}
66+
}
67+
}
3568
}
3669
}
37-
}
3870

39-
return JSON.stringify({
40-
entrypoints: assets
41-
}, null, 2);
71+
return JSON.stringify({
72+
entrypoints: assets,
73+
integrity
74+
}, null, 2);
75+
};
4276
}
4377

4478
/**
@@ -53,7 +87,7 @@ module.exports = function(plugins, webpackConfig) {
5387
filename: 'entrypoints.json',
5488
includeAllFileTypes: true,
5589
entrypoints: true,
56-
processOutput: processOutput
90+
processOutput: processOutput(webpackConfig)
5791
}),
5892
priority: PluginPriorities.AssetsPlugin
5993
});

test/WebpackConfig.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,4 +1083,33 @@ describe('WebpackConfig object', () => {
10831083
}).to.throw('Argument 1 to configureWatchOptions() must be a callback function.');
10841084
});
10851085
});
1086+
1087+
describe('enableIntegrityHashes', () => {
1088+
it('Calling it without any option', () => {
1089+
const config = createConfig();
1090+
config.enableIntegrityHashes();
1091+
1092+
expect(config.integrityAlgorithm).to.equal('sha384');
1093+
});
1094+
1095+
it('Calling it without false as a first argument disables it', () => {
1096+
const config = createConfig();
1097+
config.enableIntegrityHashes(false, 'sha1');
1098+
1099+
expect(config.integrityAlgorithm).to.be.null;
1100+
});
1101+
1102+
it('Calling it and setting the algorithm', () => {
1103+
const config = createConfig();
1104+
config.enableIntegrityHashes(true, 'sha1');
1105+
1106+
expect(config.integrityAlgorithm).to.equal('sha1');
1107+
});
1108+
1109+
it('Calling it with an invalid algorithm', () => {
1110+
const config = createConfig();
1111+
expect(() => config.enableIntegrityHashes(true, {})).to.throw('must be a string');
1112+
expect(() => config.enableIntegrityHashes(true, 'foo')).to.throw('Invalid hash algorithm');
1113+
});
1114+
});
10861115
});

test/functional.js

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ function getEntrypointData(config, entryName) {
6060
return entrypointsData.entrypoints[entryName];
6161
}
6262

63+
function getIntegrityData(config) {
64+
const entrypointsData = JSON.parse(readOutputFileContents('entrypoints.json', config));
65+
if (typeof entrypointsData.integrity === 'undefined') {
66+
throw new Error('The entrypoints.json file does not contain an integrity object!');
67+
}
68+
69+
return entrypointsData.integrity;
70+
}
71+
6372
describe('Functional tests using webpack', function() {
6473
// being functional tests, these can take quite long
6574
this.timeout(10000);
@@ -132,7 +141,8 @@ describe('Functional tests using webpack', function() {
132141
js: ['/build/runtime.js'],
133142
css: ['/build/bg.css']
134143
}
135-
}
144+
},
145+
integrity: {}
136146
});
137147

138148
done();
@@ -160,7 +170,8 @@ describe('Functional tests using webpack', function() {
160170
js: ['/build/runtime.js', '/build/vendors~main~other.js', '/build/main~other.js', '/build/other.js'],
161171
css: ['/build/main~other.css']
162172
}
163-
}
173+
},
174+
integrity: {}
164175
});
165176

166177
done();
@@ -763,7 +774,8 @@ describe('Functional tests using webpack', function() {
763774
js: ['/build/runtime.js', '/build/shared.js', '/build/other.js'],
764775
css: ['/build/shared.css']
765776
}
766-
}
777+
},
778+
integrity: {}
767779
});
768780

769781
testSetup.requestTestPage(
@@ -1848,7 +1860,8 @@ module.exports = {
18481860
js: ['/build/runtime.js', '/build/vendors~main~other.js', '/build/main~other.js', '/build/other.js'],
18491861
css: ['/build/main~other.css']
18501862
}
1851-
}
1863+
},
1864+
integrity: {}
18521865
});
18531866

18541867
// make split chunks are correct in manifest
@@ -1890,7 +1903,8 @@ module.exports = {
18901903
],
18911904
css: ['http://localhost:8080/build/main~other.css']
18921905
}
1893-
}
1906+
},
1907+
integrity: {}
18941908
});
18951909

18961910
// make split chunks are correct in manifest
@@ -1932,7 +1946,8 @@ module.exports = {
19321946
],
19331947
css: ['/subdirectory/build/main~other.css']
19341948
}
1935-
}
1949+
},
1950+
integrity: {}
19361951
});
19371952

19381953
// make split chunks are correct in manifest
@@ -1964,7 +1979,8 @@ module.exports = {
19641979
js: ['/build/runtime.js', '/build/0.js', '/build/1.js', '/build/other.js'],
19651980
css: ['/build/1.css']
19661981
}
1967-
}
1982+
},
1983+
integrity: {}
19681984
});
19691985

19701986
// make split chunks are correct in manifest
@@ -1995,7 +2011,8 @@ module.exports = {
19952011
// so, it has that filename, instead of following the normal pattern
19962012
js: ['/build/runtime.js', '/build/vendors~main~other.js', '/build/0.js', '/build/other.js']
19972013
}
1998-
}
2014+
},
2015+
integrity: {}
19992016
});
20002017

20012018
// make split chunks are correct in manifest
@@ -2098,5 +2115,78 @@ module.exports = {
20982115
});
20992116
});
21002117
});
2118+
2119+
if (!process.env.DISABLE_UNSTABLE_CHECKS) {
2120+
describe('enableIntegrityHashes() adds hashes to the entrypoints.json file', () => {
2121+
it('Using default algorithm', (done) => {
2122+
const config = createWebpackConfig('web/build', 'dev');
2123+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2124+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2125+
config.setPublicPath('/build');
2126+
config.configureSplitChunks((splitChunks) => {
2127+
splitChunks.chunks = 'all';
2128+
splitChunks.minSize = 0;
2129+
});
2130+
config.enableIntegrityHashes();
2131+
2132+
testSetup.runWebpack(config, () => {
2133+
const integrityData = getIntegrityData(config);
2134+
const expectedHashes = {
2135+
'/build/runtime.js': 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
2136+
'/build/main.js': 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
2137+
'/build/main~other.js': '4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n',
2138+
'/build/main~other.css': 'hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn',
2139+
'/build/other.js': 'ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9',
2140+
2141+
// vendors~main~other.js's hash is not tested since its
2142+
// content seems to change based on the build environment.
2143+
};
2144+
2145+
expect(integrityData.algorithm).to.equal('sha384');
2146+
2147+
for (const file in expectedHashes) {
2148+
expect(integrityData.hashes[file]).to.deep.equal(expectedHashes[file]);
2149+
}
2150+
2151+
done();
2152+
});
2153+
});
2154+
2155+
it('Using another algorithm and a different public path', (done) => {
2156+
const config = createWebpackConfig('web/build', 'dev');
2157+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2158+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2159+
config.setPublicPath('http://localhost:8090/assets');
2160+
config.setManifestKeyPrefix('assets');
2161+
config.configureSplitChunks((splitChunks) => {
2162+
splitChunks.chunks = 'all';
2163+
splitChunks.minSize = 0;
2164+
});
2165+
config.enableIntegrityHashes(true, 'md5');
2166+
2167+
testSetup.runWebpack(config, () => {
2168+
const integrityData = getIntegrityData(config);
2169+
const expectedHashes = {
2170+
'http://localhost:8090/assets/runtime.js': 'mg7CHb72gsDGpEFL9KCo7g==',
2171+
'http://localhost:8090/assets/main.js': 'lv1wLOA041Myhs9zSGGPwA==',
2172+
'http://localhost:8090/assets/main~other.js': 'DejRltgCse+f7tQHIZ3AEA==',
2173+
'http://localhost:8090/assets/main~other.css': 'foQmt62xKImGVEn/9fou8Q==',
2174+
'http://localhost:8090/assets/other.js': '1CtbEVw6vOl+/SUVHyKBbA==',
2175+
2176+
// vendors~main~other.js's hash is not tested since its
2177+
// content seems to change based on the build environment.
2178+
};
2179+
2180+
expect(integrityData.algorithm).to.equal('md5');
2181+
2182+
for (const file in expectedHashes) {
2183+
expect(integrityData.hashes[file]).to.deep.equal(expectedHashes[file]);
2184+
}
2185+
2186+
done();
2187+
});
2188+
});
2189+
});
2190+
}
21012191
});
21022192
});

test/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,15 @@ describe('Public API', () => {
414414

415415
});
416416

417+
describe('enableIntegrityHashes', () => {
418+
419+
it('should return the API object', () => {
420+
const returnedValue = api.enableIntegrityHashes();
421+
expect(returnedValue).to.equal(api);
422+
});
423+
424+
});
425+
417426
describe('isRuntimeEnvironmentConfigured', () => {
418427

419428
it('should return true if the runtime environment has been configured', () => {

0 commit comments

Comments
 (0)