Skip to content

Commit 370745b

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

File tree

6 files changed

+274
-23
lines changed

6 files changed

+274
-23
lines changed

index.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,40 @@ 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+
* Or with multiple algorithms:
1119+
*
1120+
* Encore.enableIntegrityHashes(
1121+
* Encore.isProduction(),
1122+
* ['sha256', 'sha384', 'sha512']
1123+
* );
1124+
*
1125+
* @param {bool} enabled
1126+
* @param {string|Array} algorithms
1127+
* @returns {Encore}
1128+
*/
1129+
enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
1130+
webpackConfig.enableIntegrityHashes(enabled, algorithms);
1131+
1132+
return this;
1133+
}
1134+
11011135
/**
11021136
* Is this currently a "production" build?
11031137
*

lib/WebpackConfig.js

Lines changed: 21 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.integrityAlgorithms = [];
5153

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

687+
enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
688+
if (!Array.isArray(algorithms)) {
689+
algorithms = [algorithms];
690+
}
691+
692+
const availableHashes = crypto.getHashes();
693+
for (const algorithm of algorithms) {
694+
if (typeof algorithm !== 'string') {
695+
throw new Error('Argument 2 to enableIntegrityHashes() must be a string or an array of strings.');
696+
}
697+
698+
if (!availableHashes.includes(algorithm)) {
699+
throw new Error(`Invalid hash algorithm "${algorithm}" passed to enableIntegrityHashes().`);
700+
}
701+
}
702+
703+
this.integrityAlgorithms = enabled ? algorithms : [];
704+
}
705+
685706
useDevServer() {
686707
return this.runtimeConfig.useDevServer;
687708
}

lib/plugins/entry-files-manifest.js

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,71 @@ 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 integrityAlgorithms = webpackConfig.integrityAlgorithms;
37+
const publicPath = webpackConfig.getRealPublicPath();
38+
39+
for (const asset in assets) {
40+
for (const fileType in assets[asset]) {
41+
if (!Array.isArray(assets[asset][fileType])) {
42+
assets[asset][fileType] = [assets[asset][fileType]];
43+
}
44+
45+
if (integrityAlgorithms.length) {
46+
for (const file of assets[asset][fileType]) {
47+
if (file in integrity) {
48+
continue;
49+
}
50+
51+
const filePath = path.resolve(
52+
webpackConfig.outputPath,
53+
file.replace(publicPath, '')
54+
);
55+
56+
if (fs.existsSync(filePath)) {
57+
const fileHashes = [];
58+
59+
for (const algorithm of webpackConfig.integrityAlgorithms) {
60+
const hash = crypto.createHash(algorithm);
61+
const fileContent = fs.readFileSync(filePath, 'utf8');
62+
hash.update(fileContent, 'utf8');
63+
64+
fileHashes.push(`${algorithm}-${hash.digest('base64')}`);
65+
}
66+
67+
integrity[file] = fileHashes.join(' ');
68+
}
69+
}
70+
}
3571
}
3672
}
37-
}
3873

39-
return JSON.stringify({
40-
entrypoints: assets
41-
}, null, 2);
74+
const manifestContent = { entrypoints: assets };
75+
if (integrityAlgorithms.length) {
76+
manifestContent.integrity = integrity;
77+
}
78+
79+
return JSON.stringify(manifestContent, null, 2);
80+
};
4281
}
4382

4483
/**
@@ -53,7 +92,7 @@ module.exports = function(plugins, webpackConfig) {
5392
filename: 'entrypoints.json',
5493
includeAllFileTypes: true,
5594
entrypoints: true,
56-
processOutput: processOutput
95+
processOutput: processOutput(webpackConfig)
5796
}),
5897
priority: PluginPriorities.AssetsPlugin
5998
});

test/WebpackConfig.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,4 +1083,42 @@ 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.integrityAlgorithms).to.deep.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.integrityAlgorithms).to.deep.equal([]);
1100+
});
1101+
1102+
it('Calling it with a single algorithm', () => {
1103+
const config = createConfig();
1104+
config.enableIntegrityHashes(true, 'sha1');
1105+
1106+
expect(config.integrityAlgorithms).to.deep.equal(['sha1']);
1107+
});
1108+
1109+
it('Calling it with multiple algorithms', () => {
1110+
const config = createConfig();
1111+
config.enableIntegrityHashes(true, ['sha1', 'sha256', 'sha512']);
1112+
1113+
expect(config.integrityAlgorithms).to.deep.equal(['sha1', 'sha256', 'sha512']);
1114+
});
1115+
1116+
it('Calling it with an invalid algorithm', () => {
1117+
const config = createConfig();
1118+
expect(() => config.enableIntegrityHashes(true, {})).to.throw('must be a string or an array of strings');
1119+
expect(() => config.enableIntegrityHashes(true, [1])).to.throw('must be a string or an array of strings');
1120+
expect(() => config.enableIntegrityHashes(true, 'foo')).to.throw('Invalid hash algorithm "foo"');
1121+
expect(() => config.enableIntegrityHashes(true, ['sha1', 'foo', 'sha256'])).to.throw('Invalid hash algorithm "foo"');
1122+
});
1123+
});
10861124
});

test/functional.js

Lines changed: 110 additions & 0 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);
@@ -2098,5 +2107,106 @@ module.exports = {
20982107
});
20992108
});
21002109
});
2110+
2111+
if (!process.env.DISABLE_UNSTABLE_CHECKS) {
2112+
describe('enableIntegrityHashes() adds hashes to the entrypoints.json file', () => {
2113+
it('Using default algorithm', (done) => {
2114+
const config = createWebpackConfig('web/build', 'dev');
2115+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2116+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2117+
config.setPublicPath('/build');
2118+
config.configureSplitChunks((splitChunks) => {
2119+
splitChunks.chunks = 'all';
2120+
splitChunks.minSize = 0;
2121+
});
2122+
config.enableIntegrityHashes();
2123+
2124+
testSetup.runWebpack(config, () => {
2125+
const integrityData = getIntegrityData(config);
2126+
const expectedHashes = {
2127+
'/build/runtime.js': 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
2128+
'/build/main.js': 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
2129+
'/build/main~other.js': 'sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n',
2130+
'/build/main~other.css': 'sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn',
2131+
'/build/other.js': 'sha384-ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9',
2132+
2133+
// vendors~main~other.js's hash is not tested since its
2134+
// content seems to change based on the build environment.
2135+
};
2136+
2137+
for (const file in expectedHashes) {
2138+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2139+
}
2140+
2141+
done();
2142+
});
2143+
});
2144+
2145+
it('Using another algorithm and a different public path', (done) => {
2146+
const config = createWebpackConfig('web/build', 'dev');
2147+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2148+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2149+
config.setPublicPath('http://localhost:8090/assets');
2150+
config.setManifestKeyPrefix('assets');
2151+
config.configureSplitChunks((splitChunks) => {
2152+
splitChunks.chunks = 'all';
2153+
splitChunks.minSize = 0;
2154+
});
2155+
config.enableIntegrityHashes(true, 'sha256');
2156+
2157+
testSetup.runWebpack(config, () => {
2158+
const integrityData = getIntegrityData(config);
2159+
const expectedHashes = {
2160+
'http://localhost:8090/assets/runtime.js': 'sha256-7Zze5YHq/8SPpzHbmtN7hFuexDEVMcNkYkeBJy2Uc2o=',
2161+
'http://localhost:8090/assets/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE=',
2162+
'http://localhost:8090/assets/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc=',
2163+
'http://localhost:8090/assets/main~other.css': 'sha256-KVo9sI0v6MnbxPg/xZMSn2XE7qIChWiDh1uED1tP5Fo=',
2164+
'http://localhost:8090/assets/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc=',
2165+
2166+
// vendors~main~other.js's hash is not tested since its
2167+
// content seems to change based on the build environment.
2168+
};
2169+
2170+
for (const file in expectedHashes) {
2171+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2172+
}
2173+
2174+
done();
2175+
});
2176+
});
2177+
2178+
it('Using multiple algorithms', (done) => {
2179+
const config = createWebpackConfig('web/build', 'dev');
2180+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2181+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2182+
config.setPublicPath('/build');
2183+
config.configureSplitChunks((splitChunks) => {
2184+
splitChunks.chunks = 'all';
2185+
splitChunks.minSize = 0;
2186+
});
2187+
config.enableIntegrityHashes(true, ['sha256', 'sha512']);
2188+
2189+
testSetup.runWebpack(config, () => {
2190+
const integrityData = getIntegrityData(config);
2191+
const expectedHashes = {
2192+
'/build/runtime.js': 'sha256-H1kWMiF/ZrdlqCP49sLKyoxC/snwX7EVGJPluTM4wh8= sha512-XyYHXWTEdfInnsN/ZWV0YQ+DSO8jcczHljYQkmkTZ/xAzoEfjxiQ5NYug+V3OWbvFZ7Azwqs7FbKcz8ABE9ZAg==',
2193+
'/build/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE= sha512-/wl1U/L6meBga5eeRTxPz5BxFiLmwL/kjy1NTcK0DNdxV3oUI/zZ9DEDU43Cl7XqGMnUH8pJhhFJR+1k9vZrYQ==',
2194+
'/build/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc= sha512-1xuC/Y+goI01JUPVYBQOpPY36ttTXnZFOBsTgNPCJu53b2/ccFqzeW3abV3KG5mFzo4cfSUOS7AXjj8ajp/MjA==',
2195+
'/build/main~other.css': 'sha256-6AltZJTjdVuLywCBE8qQevkscxazmWyh/19OL6cxkwY= sha512-zE1kAcqJ/jNnycEwELK7BfauEgRlK6cGrN+9urz4JI1K+s5BpglYFF9G0VOiSA7Kj3w46XX1WcjZ5w5QohBFEw==',
2196+
'/build/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc= sha512-XZjuolIG3/QW1PwAIaPCtQZvKvkPNsAsoUjQdDqlW/uStd9lBrT3w16WrBdc3qe4X11bGkyA7IQpQwN3FGkPMA==',
2197+
2198+
// vendors~main~other.js's hash is not tested since its
2199+
// content seems to change based on the build environment.
2200+
};
2201+
2202+
for (const file in expectedHashes) {
2203+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2204+
}
2205+
2206+
done();
2207+
});
2208+
});
2209+
});
2210+
}
21012211
});
21022212
});

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)