Skip to content

Commit

Permalink
feature #522 Add an enableIntegrityHashes() method to the public API …
Browse files Browse the repository at this point in the history
…(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
  • Loading branch information
weaverryan committed Mar 29, 2019
2 parents afe3797 + fa915aa commit 79874a5
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 23 deletions.
34 changes: 34 additions & 0 deletions index.js
Expand Up @@ -1153,6 +1153,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 @@ -751,6 +753,25 @@ class WebpackConfig {
this.loaderConfigurationCallbacks[name] = callback;
}

enableIntegrityHashes(enabled = true, algorithms = ['sha384']) {
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

0 comments on commit 79874a5

Please sign in to comment.