Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an enableIntegrityHashes() method to the public API #522

Merged
merged 1 commit into from Mar 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
34 changes: 34 additions & 0 deletions index.js
Expand Up @@ -1144,6 +1144,40 @@ 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'
* );
*
* Or with multiple algorithms:
*
* Encore.enableIntegrityHashes(
* Encore.isProduction(),
* ['sha256', 'sha384', 'sha512']
* );
*
* @param {bool} enabled
* @param {string|Array} algorithms
* @returns {Encore}
*/
enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
webpackConfig.enableIntegrityHashes(enabled, algorithms);

return this;
}

/**
* Is this currently a "production" build?
*
Expand Down
21 changes: 21 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.integrityAlgorithms = [];

// Features/Loaders flags
this.useVersioning = false;
Expand Down Expand Up @@ -750,6 +752,25 @@ class WebpackConfig {
this.loaderConfigurationCallbacks[name] = callback;
}

enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also forbid passing enabled = true with an empty array as second argument ? That would actually disable it.

if (!Array.isArray(algorithms)) {
algorithms = [algorithms];
}

const availableHashes = crypto.getHashes();
for (const algorithm of algorithms) {
if (typeof algorithm !== 'string') {
throw new Error('Argument 2 to enableIntegrityHashes() must be a string or an array of strings.');
}

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

this.integrityAlgorithms = enabled ? algorithms : [];
}

useDevServer() {
return this.runtimeConfig.useDevServer;
}
Expand Down
85 changes: 62 additions & 23 deletions lib/plugins/entry-files-manifest.js
Expand Up @@ -13,32 +13,71 @@ 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 integrityAlgorithms = webpackConfig.integrityAlgorithms;
const publicPath = webpackConfig.getRealPublicPath();

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

if (integrityAlgorithms.length) {
for (const file of assets[asset][fileType]) {
if (file in integrity) {
continue;
}

const filePath = path.resolve(
webpackConfig.outputPath,
file.replace(publicPath, '')
);

if (fs.existsSync(filePath)) {
const fileHashes = [];

for (const algorithm of webpackConfig.integrityAlgorithms) {
const hash = crypto.createHash(algorithm);
const fileContent = fs.readFileSync(filePath, 'utf8');
hash.update(fileContent, 'utf8');

fileHashes.push(`${algorithm}-${hash.digest('base64')}`);
}

integrity[file] = fileHashes.join(' ');
}
}
}
}
}
}

return JSON.stringify({
entrypoints: assets
}, null, 2);
const manifestContent = { entrypoints: assets };
if (integrityAlgorithms.length) {
manifestContent.integrity = integrity;
}

return JSON.stringify(manifestContent, null, 2);
};
}

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

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

expect(config.integrityAlgorithms).to.deep.equal(['sha384']);
});

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

expect(config.integrityAlgorithms).to.deep.equal([]);
});

it('Calling it with a single algorithm', () => {
const config = createConfig();
config.enableIntegrityHashes(true, 'sha1');

expect(config.integrityAlgorithms).to.deep.equal(['sha1']);
});

it('Calling it with multiple algorithms', () => {
const config = createConfig();
config.enableIntegrityHashes(true, ['sha1', 'sha256', 'sha512']);

expect(config.integrityAlgorithms).to.deep.equal(['sha1', 'sha256', 'sha512']);
});

it('Calling it with an invalid algorithm', () => {
const config = createConfig();
expect(() => config.enableIntegrityHashes(true, {})).to.throw('must be a string or an array of strings');
expect(() => config.enableIntegrityHashes(true, [1])).to.throw('must be a string or an array of strings');
expect(() => config.enableIntegrityHashes(true, 'foo')).to.throw('Invalid hash algorithm "foo"');
expect(() => config.enableIntegrityHashes(true, ['sha1', 'foo', 'sha256'])).to.throw('Invalid hash algorithm "foo"');
});
});
});
110 changes: 110 additions & 0 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 @@ -2254,5 +2263,106 @@ 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': 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
'/build/main.js': 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
'/build/main~other.js': 'sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n',
'/build/main~other.css': 'sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn',
'/build/other.js': 'sha384-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.
};

for (const file in expectedHashes) {
expect(integrityData[file]).to.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, 'sha256');

testSetup.runWebpack(config, () => {
const integrityData = getIntegrityData(config);
const expectedHashes = {
'http://localhost:8090/assets/runtime.js': 'sha256-7Zze5YHq/8SPpzHbmtN7hFuexDEVMcNkYkeBJy2Uc2o=',
'http://localhost:8090/assets/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE=',
'http://localhost:8090/assets/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc=',
'http://localhost:8090/assets/main~other.css': 'sha256-KVo9sI0v6MnbxPg/xZMSn2XE7qIChWiDh1uED1tP5Fo=',
'http://localhost:8090/assets/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc=',

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

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

done();
});
});

it('Using multiple algorithms', (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(true, ['sha256', 'sha512']);

testSetup.runWebpack(config, () => {
const integrityData = getIntegrityData(config);
const expectedHashes = {
'/build/runtime.js': 'sha256-H1kWMiF/ZrdlqCP49sLKyoxC/snwX7EVGJPluTM4wh8= sha512-XyYHXWTEdfInnsN/ZWV0YQ+DSO8jcczHljYQkmkTZ/xAzoEfjxiQ5NYug+V3OWbvFZ7Azwqs7FbKcz8ABE9ZAg==',
'/build/main.js': 'sha256-RtW3TYA1SBHUGuBnIBBJZ7etIGyYisjargouvET4sFE= sha512-/wl1U/L6meBga5eeRTxPz5BxFiLmwL/kjy1NTcK0DNdxV3oUI/zZ9DEDU43Cl7XqGMnUH8pJhhFJR+1k9vZrYQ==',
'/build/main~other.js': 'sha256-q9xPQWa0UBbMPUNmhDaDuBFjV2gZU6ICiKzLN7jPccc= sha512-1xuC/Y+goI01JUPVYBQOpPY36ttTXnZFOBsTgNPCJu53b2/ccFqzeW3abV3KG5mFzo4cfSUOS7AXjj8ajp/MjA==',
'/build/main~other.css': 'sha256-6AltZJTjdVuLywCBE8qQevkscxazmWyh/19OL6cxkwY= sha512-zE1kAcqJ/jNnycEwELK7BfauEgRlK6cGrN+9urz4JI1K+s5BpglYFF9G0VOiSA7Kj3w46XX1WcjZ5w5QohBFEw==',
'/build/other.js': 'sha256-rxT6mp9VrLO1++6G3g/VSLGisznX838ALokQhD3Jmyc= sha512-XZjuolIG3/QW1PwAIaPCtQZvKvkPNsAsoUjQdDqlW/uStd9lBrT3w16WrBdc3qe4X11bGkyA7IQpQwN3FGkPMA==',

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

for (const file in expectedHashes) {
expect(integrityData[file]).to.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