Skip to content

Commit

Permalink
Add an enableIntegrityHashes() method to the public API
Browse files Browse the repository at this point in the history
  • Loading branch information
Lyrkan committed Feb 14, 2019
1 parent f35841a commit 8584814
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 31 deletions.
27 changes: 27 additions & 0 deletions index.js
Expand Up @@ -1098,6 +1098,33 @@ class Encore {
return this;
}

/**
* If enabled, add integrity hashes to the entrypoints.json
* file for all the files it references.
*
* These hashes can then be used, for instance, in the "integrity"
* attributes of <script> and <style> tags to enable subresource-
* integrity checks in the browser.
*
* https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
*
* For example:
*
* Encore.enableIntegrityHashes(
* Encore.isProduction(),
* 'sha384'
* );
*
* @param {bool} enabled
* @param {string} algorithm
* @returns {Encore}
*/
enableIntegrityHashes(enabled = true, algorithm = 'sha384') {
webpackConfig.enableIntegrityHashes(enabled, algorithm);

return this;
}

/**
* Is this currently a "production" build?
*
Expand Down
15 changes: 15 additions & 0 deletions lib/WebpackConfig.js
Expand Up @@ -11,6 +11,7 @@

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const logger = require('./logger');

/**
Expand Down Expand Up @@ -48,6 +49,7 @@ class WebpackConfig {
this.configuredFilenames = {};
this.aliases = {};
this.externals = [];
this.integrityAlgorithm = null;

// Features/Loaders flags
this.useVersioning = false;
Expand Down Expand Up @@ -682,6 +684,19 @@ class WebpackConfig {
});
}

enableIntegrityHashes(enabled = true, algorithm = 'sha384') {
if (typeof algorithm !== 'string') {
throw new Error('Argument 2 to enableIntegrityHashes() must be a string.');
}

const availableHashes = crypto.getHashes();
if (!availableHashes.includes(algorithm)) {
throw new Error(`Invalid hash algorithm "${algorithm}" passed to enableIntegrityHashes().`);
}

this.integrityAlgorithm = enabled ? algorithm : null;
}

useDevServer() {
return this.runtimeConfig.useDevServer;
}
Expand Down
80 changes: 57 additions & 23 deletions lib/plugins/entry-files-manifest.js
Expand Up @@ -13,32 +13,66 @@ const PluginPriorities = require('./plugin-priorities');
const sharedEntryTmpName = require('../utils/sharedEntryTmpName');
const copyEntryTmpName = require('../utils/copyEntryTmpName');
const AssetsPlugin = require('assets-webpack-plugin');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

function processOutput(assets) {
for (const entry of [copyEntryTmpName, sharedEntryTmpName]) {
delete assets[entry];
}

// with --watch or dev-server, subsequent calls will include
// the original assets (so, assets.entrypoints) + the new
// assets (which will have their original structure). We
// delete the entrypoints key, and then process the new assets
// like normal below
delete assets.entrypoints;

// This will iterate over all the entry points and convert the
// one file entries into an array of one entry since that was how the entry point file was before this change.
for (const asset in assets) {
for (const fileType in assets[asset]) {
if (!Array.isArray(assets[asset][fileType])) {
assets[asset][fileType] = [assets[asset][fileType]];
function processOutput(webpackConfig) {
return (assets) => {
for (const entry of [copyEntryTmpName, sharedEntryTmpName]) {
delete assets[entry];
}

// with --watch or dev-server, subsequent calls will include
// the original assets (so, assets.entrypoints) + the new
// assets (which will have their original structure). We
// delete the entrypoints key, and then process the new assets
// like normal below
delete assets.entrypoints;

// This will iterate over all the entry points and convert the
// one file entries into an array of one entry since that was how the entry point file was before this change.
const integrity = {};
const integrityAlgorithm = webpackConfig.integrityAlgorithm;
const publicPath = webpackConfig.getRealPublicPath();

if (integrityAlgorithm) {
integrity.algorithm = integrityAlgorithm;
integrity.hashes = {};

for (const asset in assets) {
for (const fileType in assets[asset]) {
if (!Array.isArray(assets[asset][fileType])) {
assets[asset][fileType] = [assets[asset][fileType]];
}

if (webpackConfig.integrityAlgorithm) {
for (const file of assets[asset][fileType]) {
if (!(file in integrity.hashes)) {
const filePath = path.resolve(
webpackConfig.outputPath,
file.replace(publicPath, '')
);

if (fs.existsSync(filePath)) {
const hash = crypto.createHash(webpackConfig.integrityAlgorithm);
const fileContent = fs.readFileSync(filePath, 'utf8');
hash.update(fileContent, 'utf8');

integrity.hashes[file] = hash.digest('base64');
}
}
}
}
}
}
}
}

return JSON.stringify({
entrypoints: assets
}, null, 2);
return JSON.stringify({
entrypoints: assets,
integrity
}, null, 2);
};
}

/**
Expand All @@ -53,7 +87,7 @@ module.exports = function(plugins, webpackConfig) {
filename: 'entrypoints.json',
includeAllFileTypes: true,
entrypoints: true,
processOutput: processOutput
processOutput: processOutput(webpackConfig)
}),
priority: PluginPriorities.AssetsPlugin
});
Expand Down
29 changes: 29 additions & 0 deletions test/WebpackConfig.js
Expand Up @@ -1083,4 +1083,33 @@ describe('WebpackConfig object', () => {
}).to.throw('Argument 1 to configureWatchOptions() must be a callback function.');
});
});

describe('enableIntegrityHashes', () => {
it('Calling it without any option', () => {
const config = createConfig();
config.enableIntegrityHashes();

expect(config.integrityAlgorithm).to.equal('sha384');
});

it('Calling it without false as a first argument disables it', () => {
const config = createConfig();
config.enableIntegrityHashes(false, 'sha1');

expect(config.integrityAlgorithm).to.be.null;
});

it('Calling it and setting the algorithm', () => {
const config = createConfig();
config.enableIntegrityHashes(true, 'sha1');

expect(config.integrityAlgorithm).to.equal('sha1');
});

it('Calling it with an invalid algorithm', () => {
const config = createConfig();
expect(() => config.enableIntegrityHashes(true, {})).to.throw('must be a string');
expect(() => config.enableIntegrityHashes(true, 'foo')).to.throw('Invalid hash algorithm');
});
});
});
106 changes: 98 additions & 8 deletions test/functional.js
Expand Up @@ -60,6 +60,15 @@ function getEntrypointData(config, entryName) {
return entrypointsData.entrypoints[entryName];
}

function getIntegrityData(config) {
const entrypointsData = JSON.parse(readOutputFileContents('entrypoints.json', config));
if (typeof entrypointsData.integrity === 'undefined') {
throw new Error('The entrypoints.json file does not contain an integrity object!');
}

return entrypointsData.integrity;
}

describe('Functional tests using webpack', function() {
// being functional tests, these can take quite long
this.timeout(10000);
Expand Down Expand Up @@ -132,7 +141,8 @@ describe('Functional tests using webpack', function() {
js: ['/build/runtime.js'],
css: ['/build/bg.css']
}
}
},
integrity: {}
});

done();
Expand Down Expand Up @@ -160,7 +170,8 @@ describe('Functional tests using webpack', function() {
js: ['/build/runtime.js', '/build/vendors~main~other.js', '/build/main~other.js', '/build/other.js'],
css: ['/build/main~other.css']
}
}
},
integrity: {}
});

done();
Expand Down Expand Up @@ -763,7 +774,8 @@ describe('Functional tests using webpack', function() {
js: ['/build/runtime.js', '/build/shared.js', '/build/other.js'],
css: ['/build/shared.css']
}
}
},
integrity: {}
});

testSetup.requestTestPage(
Expand Down Expand Up @@ -1848,7 +1860,8 @@ module.exports = {
js: ['/build/runtime.js', '/build/vendors~main~other.js', '/build/main~other.js', '/build/other.js'],
css: ['/build/main~other.css']
}
}
},
integrity: {}
});

// make split chunks are correct in manifest
Expand Down Expand Up @@ -1890,7 +1903,8 @@ module.exports = {
],
css: ['http://localhost:8080/build/main~other.css']
}
}
},
integrity: {}
});

// make split chunks are correct in manifest
Expand Down Expand Up @@ -1932,7 +1946,8 @@ module.exports = {
],
css: ['/subdirectory/build/main~other.css']
}
}
},
integrity: {}
});

// make split chunks are correct in manifest
Expand Down Expand Up @@ -1964,7 +1979,8 @@ module.exports = {
js: ['/build/runtime.js', '/build/0.js', '/build/1.js', '/build/other.js'],
css: ['/build/1.css']
}
}
},
integrity: {}
});

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

// make split chunks are correct in manifest
Expand Down Expand Up @@ -2098,5 +2115,78 @@ module.exports = {
});
});
});

if (!process.env.DISABLE_UNSTABLE_CHECKS) {
describe('enableIntegrityHashes() adds hashes to the entrypoints.json file', () => {
it('Using default algorithm', (done) => {
const config = createWebpackConfig('web/build', 'dev');
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
config.setPublicPath('/build');
config.configureSplitChunks((splitChunks) => {
splitChunks.chunks = 'all';
splitChunks.minSize = 0;
});
config.enableIntegrityHashes();

testSetup.runWebpack(config, () => {
const integrityData = getIntegrityData(config);
const expectedHashes = {
'/build/runtime.js': 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
'/build/main.js': 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
'/build/main~other.js': '4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n',
'/build/main~other.css': 'hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn',
'/build/other.js': 'ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9',

// vendors~main~other.js's hash is not tested since its
// content seems to change based on the build environment.
};

expect(integrityData.algorithm).to.equal('sha384');

for (const file in expectedHashes) {
expect(integrityData.hashes[file]).to.deep.equal(expectedHashes[file]);
}

done();
});
});

it('Using another algorithm and a different public path', (done) => {
const config = createWebpackConfig('web/build', 'dev');
config.addEntry('main', ['./css/roboto_font.css', './js/no_require', 'vue']);
config.addEntry('other', ['./css/roboto_font.css', 'vue']);
config.setPublicPath('http://localhost:8090/assets');
config.setManifestKeyPrefix('assets');
config.configureSplitChunks((splitChunks) => {
splitChunks.chunks = 'all';
splitChunks.minSize = 0;
});
config.enableIntegrityHashes(true, 'md5');

testSetup.runWebpack(config, () => {
const integrityData = getIntegrityData(config);
const expectedHashes = {
'http://localhost:8090/assets/runtime.js': 'mg7CHb72gsDGpEFL9KCo7g==',
'http://localhost:8090/assets/main.js': 'lv1wLOA041Myhs9zSGGPwA==',
'http://localhost:8090/assets/main~other.js': 'DejRltgCse+f7tQHIZ3AEA==',
'http://localhost:8090/assets/main~other.css': 'foQmt62xKImGVEn/9fou8Q==',
'http://localhost:8090/assets/other.js': '1CtbEVw6vOl+/SUVHyKBbA==',

// vendors~main~other.js's hash is not tested since its
// content seems to change based on the build environment.
};

expect(integrityData.algorithm).to.equal('md5');

for (const file in expectedHashes) {
expect(integrityData.hashes[file]).to.deep.equal(expectedHashes[file]);
}

done();
});
});
});
}
});
});
9 changes: 9 additions & 0 deletions test/index.js
Expand Up @@ -414,6 +414,15 @@ describe('Public API', () => {

});

describe('enableIntegrityHashes', () => {

it('should return the API object', () => {
const returnedValue = api.enableIntegrityHashes();
expect(returnedValue).to.equal(api);
});

});

describe('isRuntimeEnvironmentConfigured', () => {

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

0 comments on commit 8584814

Please sign in to comment.