Skip to content

Commit 79874a5

Browse files
committed
feature #522 Add an enableIntegrityHashes() method to the public API (Lyrkan)
This PR was merged into the master branch. Discussion ---------- Add an enableIntegrityHashes() method to the public API This PR adds an `Encore.enableIntegrityHashes()` method that allows to compute integrity hashes of all the files referenced in the `entrypoints.json` (closes #418). For instance: ```js // Enable it for all builds with the // default hash algorithm (sha384) Encore.enableIntegrityHashes(); // Enable it only in production with // a custom hash algorithm Encore.enableIntegrityHashes( Encore.isProduction(), 'sha512' ); ``` And here is the resulting `entrypoints.json` file: ```json { "entrypoints": { "main": { "css": [ "/main.3d1dcb7e.css" ], "js": [ "/main.7c6b7c81.js" ] } }, "integrity": { "/main.3d1dcb7e.css": "sha384-ce7d1nV3CFoSIfinwm53befb9CMHNAAlPEb61deOf3zBvpXK9lct44/U2ieSOKt4", "/main.7c6b7c81.js": "sha384-kHFhNTJlbSuDijSTimOHZGTxKzlLYCWc03AmmRSAE3b173SMlGiQG6uasAzl29+0" } } ``` Commits ------- fa915aa Add an enableIntegrityHashes() method to the public API
2 parents afe3797 + fa915aa commit 79874a5

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
@@ -1153,6 +1153,40 @@ class Encore {
11531153
return this;
11541154
}
11551155

1156+
/**
1157+
* If enabled, add integrity hashes to the entrypoints.json
1158+
* file for all the files it references.
1159+
*
1160+
* These hashes can then be used, for instance, in the "integrity"
1161+
* attributes of <script> and <style> tags to enable subresource-
1162+
* integrity checks in the browser.
1163+
*
1164+
* https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
1165+
*
1166+
* For example:
1167+
*
1168+
* Encore.enableIntegrityHashes(
1169+
* Encore.isProduction(),
1170+
* 'sha384'
1171+
* );
1172+
*
1173+
* Or with multiple algorithms:
1174+
*
1175+
* Encore.enableIntegrityHashes(
1176+
* Encore.isProduction(),
1177+
* ['sha256', 'sha384', 'sha512']
1178+
* );
1179+
*
1180+
* @param {bool} enabled
1181+
* @param {string|Array} algorithms
1182+
* @returns {Encore}
1183+
*/
1184+
enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
1185+
webpackConfig.enableIntegrityHashes(enabled, algorithms);
1186+
1187+
return this;
1188+
}
1189+
11561190
/**
11571191
* Is this currently a "production" build?
11581192
*

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;
@@ -751,6 +753,25 @@ class WebpackConfig {
751753
this.loaderConfigurationCallbacks[name] = callback;
752754
}
753755

756+
enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
757+
if (!Array.isArray(algorithms)) {
758+
algorithms = [algorithms];
759+
}
760+
761+
const availableHashes = crypto.getHashes();
762+
for (const algorithm of algorithms) {
763+
if (typeof algorithm !== 'string') {
764+
throw new Error('Argument 2 to enableIntegrityHashes() must be a string or an array of strings.');
765+
}
766+
767+
if (!availableHashes.includes(algorithm)) {
768+
throw new Error(`Invalid hash algorithm "${algorithm}" passed to enableIntegrityHashes().`);
769+
}
770+
}
771+
772+
this.integrityAlgorithms = enabled ? algorithms : [];
773+
}
774+
754775
useDevServer() {
755776
return this.runtimeConfig.useDevServer;
756777
}

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
@@ -1173,4 +1173,42 @@ describe('WebpackConfig object', () => {
11731173
}).to.throw('Argument 2 to configureLoaderRule() must be a callback function.');
11741174
});
11751175
});
1176+
1177+
describe('enableIntegrityHashes', () => {
1178+
it('Calling it without any option', () => {
1179+
const config = createConfig();
1180+
config.enableIntegrityHashes();
1181+
1182+
expect(config.integrityAlgorithms).to.deep.equal(['sha384']);
1183+
});
1184+
1185+
it('Calling it without false as a first argument disables it', () => {
1186+
const config = createConfig();
1187+
config.enableIntegrityHashes(false, 'sha1');
1188+
1189+
expect(config.integrityAlgorithms).to.deep.equal([]);
1190+
});
1191+
1192+
it('Calling it with a single algorithm', () => {
1193+
const config = createConfig();
1194+
config.enableIntegrityHashes(true, 'sha1');
1195+
1196+
expect(config.integrityAlgorithms).to.deep.equal(['sha1']);
1197+
});
1198+
1199+
it('Calling it with multiple algorithms', () => {
1200+
const config = createConfig();
1201+
config.enableIntegrityHashes(true, ['sha1', 'sha256', 'sha512']);
1202+
1203+
expect(config.integrityAlgorithms).to.deep.equal(['sha1', 'sha256', 'sha512']);
1204+
});
1205+
1206+
it('Calling it with an invalid algorithm', () => {
1207+
const config = createConfig();
1208+
expect(() => config.enableIntegrityHashes(true, {})).to.throw('must be a string or an array of strings');
1209+
expect(() => config.enableIntegrityHashes(true, [1])).to.throw('must be a string or an array of strings');
1210+
expect(() => config.enableIntegrityHashes(true, 'foo')).to.throw('Invalid hash algorithm "foo"');
1211+
expect(() => config.enableIntegrityHashes(true, ['sha1', 'foo', 'sha256'])).to.throw('Invalid hash algorithm "foo"');
1212+
});
1213+
});
11761214
});

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);
@@ -2254,5 +2263,106 @@ module.exports = {
22542263
});
22552264
});
22562265
});
2266+
2267+
if (!process.env.DISABLE_UNSTABLE_CHECKS) {
2268+
describe('enableIntegrityHashes() adds hashes to the entrypoints.json file', () => {
2269+
it('Using default algorithm', (done) => {
2270+
const config = createWebpackConfig('web/build', 'dev');
2271+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2272+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2273+
config.setPublicPath('/build');
2274+
config.configureSplitChunks((splitChunks) => {
2275+
splitChunks.chunks = 'all';
2276+
splitChunks.minSize = 0;
2277+
});
2278+
config.enableIntegrityHashes();
2279+
2280+
testSetup.runWebpack(config, () => {
2281+
const integrityData = getIntegrityData(config);
2282+
const expectedHashes = {
2283+
'/build/runtime.js': 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
2284+
'/build/main.js': 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
2285+
'/build/main~other.js': 'sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n',
2286+
'/build/main~other.css': 'sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn',
2287+
'/build/other.js': 'sha384-ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9',
2288+
2289+
// vendors~main~other.js's hash is not tested since its
2290+
// content seems to change based on the build environment.
2291+
};
2292+
2293+
for (const file in expectedHashes) {
2294+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2295+
}
2296+
2297+
done();
2298+
});
2299+
});
2300+
2301+
it('Using another algorithm and a different public path', (done) => {
2302+
const config = createWebpackConfig('web/build', 'dev');
2303+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2304+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2305+
config.setPublicPath('http://localhost:8090/assets');
2306+
config.setManifestKeyPrefix('assets');
2307+
config.configureSplitChunks((splitChunks) => {
2308+
splitChunks.chunks = 'all';
2309+
splitChunks.minSize = 0;
2310+
});
2311+
config.enableIntegrityHashes(true, 'sha256');
2312+
2313+
testSetup.runWebpack(config, () => {
2314+
const integrityData = getIntegrityData(config);
2315+
const expectedHashes = {
2316+
'http://localhost:8090/assets/runtime.js': 'sha256-7Zze5YHq/8SPpzHbmtN7hFuexDEVMcNkYkeBJy2Uc2o=',
2317+
'http://localhost:8090/assets/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE=',
2318+
'http://localhost:8090/assets/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc=',
2319+
'http://localhost:8090/assets/main~other.css': 'sha256-KVo9sI0v6MnbxPg/xZMSn2XE7qIChWiDh1uED1tP5Fo=',
2320+
'http://localhost:8090/assets/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc=',
2321+
2322+
// vendors~main~other.js's hash is not tested since its
2323+
// content seems to change based on the build environment.
2324+
};
2325+
2326+
for (const file in expectedHashes) {
2327+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2328+
}
2329+
2330+
done();
2331+
});
2332+
});
2333+
2334+
it('Using multiple algorithms', (done) => {
2335+
const config = createWebpackConfig('web/build', 'dev');
2336+
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
2337+
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
2338+
config.setPublicPath('/build');
2339+
config.configureSplitChunks((splitChunks) => {
2340+
splitChunks.chunks = 'all';
2341+
splitChunks.minSize = 0;
2342+
});
2343+
config.enableIntegrityHashes(true, ['sha256', 'sha512']);
2344+
2345+
testSetup.runWebpack(config, () => {
2346+
const integrityData = getIntegrityData(config);
2347+
const expectedHashes = {
2348+
'/build/runtime.js': 'sha256-H1kWMiF/ZrdlqCP49sLKyoxC/snwX7EVGJPluTM4wh8= sha512-XyYHXWTEdfInnsN/ZWV0YQ+DSO8jcczHljYQkmkTZ/xAzoEfjxiQ5NYug+V3OWbvFZ7Azwqs7FbKcz8ABE9ZAg==',
2349+
'/build/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE= sha512-/wl1U/L6meBga5eeRTxPz5BxFiLmwL/kjy1NTcK0DNdxV3oUI/zZ9DEDU43Cl7XqGMnUH8pJhhFJR+1k9vZrYQ==',
2350+
'/build/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc= sha512-1xuC/Y+goI01JUPVYBQOpPY36ttTXnZFOBsTgNPCJu53b2/ccFqzeW3abV3KG5mFzo4cfSUOS7AXjj8ajp/MjA==',
2351+
'/build/main~other.css': 'sha256-6AltZJTjdVuLywCBE8qQevkscxazmWyh/19OL6cxkwY= sha512-zE1kAcqJ/jNnycEwELK7BfauEgRlK6cGrN+9urz4JI1K+s5BpglYFF9G0VOiSA7Kj3w46XX1WcjZ5w5QohBFEw==',
2352+
'/build/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc= sha512-XZjuolIG3/QW1PwAIaPCtQZvKvkPNsAsoUjQdDqlW/uStd9lBrT3w16WrBdc3qe4X11bGkyA7IQpQwN3FGkPMA==',
2353+
2354+
// vendors~main~other.js's hash is not tested since its
2355+
// content seems to change based on the build environment.
2356+
};
2357+
2358+
for (const file in expectedHashes) {
2359+
expect(integrityData[file]).to.equal(expectedHashes[file]);
2360+
}
2361+
2362+
done();
2363+
});
2364+
});
2365+
});
2366+
}
22572367
});
22582368
});

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)