From 6265b7e51d8eb20cf4ee59cfdc540a2559855c66 Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Wed, 17 Mar 2021 00:26:46 +0000 Subject: [PATCH 1/8] Generate hashes for external scripts and styles On beforeAssetTagGeneration, we collect a list of files known to Webpack that we might need to include hashes of in the CSP. Later, in beforeEmit, we check which of those files ended up in the generated HTML, and calculate the hashes of those files to include in the CSP. --- plugin.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/plugin.js b/plugin.js index 56b927c..527b9c1 100644 --- a/plugin.js +++ b/plugin.js @@ -5,6 +5,7 @@ const compact = require('lodash/compact'); const flatten = require('lodash/flatten'); const isFunction = require('lodash/isFunction'); const get = require('lodash/get'); +const path = require('path'); // Attempt to load HtmlWebpackPlugin@4 // Borrowed from https://github.com/waysact/webpack-subresource-integrity/blob/master/index.js @@ -331,14 +332,34 @@ class CspHtmlWebpackPlugin { const scriptShas = this.getShas($, 'script-src', 'script:not([src])'); const styleShas = this.getShas($, 'style-src', 'style:not([href])'); + const includedScripts = $('script[src]') + .map((i, element) => $(element).attr('src')) + .get(); + const includedStyles = $('link[rel="stylesheet"]') + .map((i, element) => $(element).attr('href')) + .get(); + + const linkedScriptShas = this.scriptFilesToHash + .filter((filename) => + includedScripts.includes(path.join(this.publicPath, filename)) + ) + .map((filename) => this.hash(compilation.assets[filename].source())); + const linkedStyleShas = this.styleFilesToHash + .filter((filename) => + includedStyles.includes(path.join(this.publicPath, filename)) + ) + .map((filename) => this.hash(compilation.assets[filename].source())); + const builtPolicy = this.buildPolicy({ ...this.policy, 'script-src': flatten([this.policy['script-src']]).concat( scriptShas, + linkedScriptShas, scriptNonce ), 'style-src': flatten([this.policy['style-src']]).concat( styleShas, + linkedStyleShas, styleNonce ), }); @@ -348,6 +369,30 @@ class CspHtmlWebpackPlugin { return compileCb(null, htmlPluginData); } + /** + * Collect lists of files whose hashes could be included in the CSP + * @param htmlPluginData + * @param compileCb + */ + getFilesToHash(htmlPluginData, compileCb) { + this.publicPath = htmlPluginData.assets.publicPath; + if (this.hashEnabled['script-src'] !== false) { + this.scriptFilesToHash = htmlPluginData.assets.js.map((filename) => + path.relative(this.publicPath, filename) + ); + } else { + this.scriptFilesToHash = []; + } + if (this.hashEnabled['style-src'] !== false) { + this.styleFilesToHash = htmlPluginData.assets.css.map((filename) => + path.relative(this.publicPath, filename) + ); + } else { + this.styleFilesToHash = []; + } + return compileCb(null, htmlPluginData); + } + /** * Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template * @param compiler @@ -362,6 +407,10 @@ class CspHtmlWebpackPlugin { 'CspHtmlWebpackPlugin', this.processCsp.bind(this, compilation) ); + HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync( + 'CspHtmlWebpackPlugin', + this.getFilesToHash.bind(this) + ); }); } } From 591276caf5a86ecf62e1fcaf2946905528399132 Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Wed, 17 Mar 2021 00:57:50 +0000 Subject: [PATCH 2/8] Update existing tests to include bundle hash Each test generates an index.bundle.js; the hash of this is now included in the CSP. --- plugin.jest.js | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/plugin.jest.js b/plugin.jest.js index 3e26e0f..1c5325e 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -136,7 +136,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'"; expect(csps['index.html']).toEqual(expected); @@ -168,7 +168,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self' https://slack.com;" + " object-src 'none';" + - " script-src 'self' 'nonce-mockedbase64string-1';" + + " script-src 'self' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'self';" + " font-src 'self' 'https://a-slack-edge.com';" + " connect-src 'self'"; @@ -199,7 +199,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'self' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'self' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'self' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'"; expect(csps['index.html']).toEqual(expected); @@ -337,7 +337,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self' https://slack.com;" + " object-src 'none';" + - " script-src 'self' 'nonce-mockedbase64string-1';" + + " script-src 'self' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'self';" + " font-src 'self' 'https://a-slack-edge.com';" + " connect-src 'self'"; @@ -376,7 +376,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self' https://slack.com;" + // this should be included as it's not defined in the HtmlWebpackPlugin instance " object-src 'none';" + // this comes from the default policy - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + // this comes from the default policy + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + // this comes from the default policy " style-src 'unsafe-inline' 'self' 'unsafe-eval';" + // this comes from the default policy " font-src 'https://a-slack-edge.com' 'https://b-slack-edge.com'"; // this should only include the HtmlWebpackPlugin instance policy @@ -418,13 +418,13 @@ describe('CspHtmlWebpackPlugin', () => { const expectedCustom = "base-uri 'self';" + " object-src 'none';" + - " script-src 'https://a-slack-edge.com' 'nonce-mockedbase64string-1';" + + " script-src 'https://a-slack-edge.com' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'https://b-slack-edge.com'"; const expectedDefault = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-2';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval'"; expect(csps['index-csp.html']).toEqual(expectedCustom); @@ -523,13 +523,13 @@ describe('CspHtmlWebpackPlugin', () => { const expected1 = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='"; const expected2 = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='"; // no nonces in either one of the script-src or style-src policies @@ -579,7 +579,7 @@ describe('CspHtmlWebpackPlugin', () => { const expectedHashes = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-6'"; // no hashes in index-no-hashes script-src or style-src policies @@ -623,13 +623,13 @@ describe('CspHtmlWebpackPlugin', () => { const expectedNoNonce = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ='"; const expectedNonce = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'"; // no nonce in index-no-nonce script-src or style-src policies @@ -773,7 +773,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval'"; expect(csps['index.html']).toEqual(expected); @@ -799,7 +799,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval'"; expect(csps['index.html']).toEqual(expected); @@ -819,7 +819,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval'"; expect(csps['index.html']).toEqual(expected); @@ -857,7 +857,7 @@ describe('CspHtmlWebpackPlugin', () => { describe('Custom process function', () => { it('Allows the process function to be overwritten', (done) => { const processFn = jest.fn(); - const builtPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; + const builtPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -896,8 +896,8 @@ describe('CspHtmlWebpackPlugin', () => { it('only overwrites the processFn for the HtmlWebpackInstance where it has been defined', (done) => { const processFn = jest.fn(); - const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; - const index2BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-6'`; + const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; + const index2BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-6'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -951,7 +951,7 @@ describe('CspHtmlWebpackPlugin', () => { ) { compilation.emitAsset('csp.conf', new RawSource(builtPolicy)); } - const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; + const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-ixjZMYNfWQWawUHioWOx2jBsTmfxucX7IlwsMt2jWvc=' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-MqG77yUiqBo4MMVZAl09WSafnQY4Uu3cSdZPKxaf9sQ=' 'nonce-mockedbase64string-3'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -1029,7 +1029,7 @@ describe('CspHtmlWebpackPlugin', () => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-1';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-J7wa7HNc5Nb9SvdpRj1UEzZlXOJERU6Mw8r5DSsL1Go=' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-JUH8Xh1Os2tA1KU3Lfxn5uZXj2Q/a/i0UVMzpWO4uOU='"; expect(csps['index.html']).toEqual(expected); @@ -1070,7 +1070,7 @@ describe('CspHtmlWebpackPlugin', () => { // csp has been added in expect(xhtmlContents).toContain( - `` + `` ); done(); From ee19fd58bb8cd3da367c902a0293f2f11c1aae1f Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Wed, 17 Mar 2021 01:02:18 +0000 Subject: [PATCH 3/8] Add test for external script/style hash generation This adds css-loader and mini-css-extract-plugin in order to test hash generation for CSS files. --- package-lock.json | 162 ++++++++++++++++++ package.json | 2 + plugin.jest.js | 42 +++++ .../fixtures/external-scripts-styles.html | 13 ++ test-utils/fixtures/index-styled.js | 1 + test-utils/fixtures/index.css | 3 + test-utils/webpack-helpers.js | 10 +- 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 test-utils/fixtures/external-scripts-styles.html create mode 100644 test-utils/fixtures/index-styled.js create mode 100644 test-utils/fixtures/index.css diff --git a/package-lock.json b/package-lock.json index 2a153ec..718e190 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2243,6 +2243,43 @@ "which": "^1.2.9" } }, + "css-loader": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.1.3.tgz", + "integrity": "sha512-CoPZvyh8sLiGARK3gqczpfdedbM74klGWurF2CsNZ2lhNaXdLIUks+3Mfax3WBeRuHoglU+m7KG/+7gY6G4aag==", + "dev": true, + "requires": { + "camelcase": "^6.2.0", + "cssesc": "^3.0.0", + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.8", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.4" + }, + "dependencies": { + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "css-select": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", @@ -2261,6 +2298,12 @@ "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", "dev": true }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -3675,6 +3718,12 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -3769,6 +3818,12 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5390,6 +5445,29 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "mini-css-extract-plugin": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.9.tgz", + "integrity": "sha512-Ac4s+xhVbqlyhXS5J/Vh/QXUz3ycXlCqoCPpg0vdfhsIBH9eg/It/9L1r1XhSCH737M1lqcWnMuWL13zcygn5A==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "webpack-sources": "^1.1.0" + }, + "dependencies": { + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + } + } + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -5432,6 +5510,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "nanoid": { + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", + "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==", + "dev": true + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -6097,6 +6181,78 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "postcss": { + "version": "8.2.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.8.tgz", + "integrity": "sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw==", + "dev": true, + "requires": { + "colorette": "^1.2.2", + "nanoid": "^3.1.20", + "source-map": "^0.6.1" + }, + "dependencies": { + "colorette": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", + "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", + "dev": true + } + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", + "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7462,6 +7618,12 @@ "set-value": "^2.0.1" } }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, "unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", diff --git a/package.json b/package.json index 96443eb..fbccefc 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "devDependencies": { "babel-jest": "^26.6.3", "codecov": "^3.8.1", + "css-loader": "^5.1.3", "eslint": "^7.16.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^7.1.0", @@ -47,6 +48,7 @@ "html-webpack-plugin": "^5.0.0-alpha.15", "jest": "^26.6.3", "memory-fs": "^0.5.0", + "mini-css-extract-plugin": "^1.3.9", "prettier": "^2.2.1", "webpack": "^5.10.1", "webpack-sources": "^2.2.0" diff --git a/plugin.jest.js b/plugin.jest.js index 1c5325e..5585f0a 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -2,6 +2,7 @@ const path = require('path'); const crypto = require('crypto'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { RawSource } = require('webpack-sources'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const { WEBPACK_OUTPUT_DIR, createWebpackConfig, @@ -144,6 +145,47 @@ describe('CspHtmlWebpackPlugin', () => { }); }); + it('inserts hashes for linked scripts and styles from the same Webpack build', (done) => { + const config = createWebpackConfig( + [ + new HtmlWebpackPlugin({ + filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), + template: path.join( + __dirname, + 'test-utils', + 'fixtures', + 'external-scripts-styles.html' + ), + }), + new MiniCssExtractPlugin(), + new CspHtmlWebpackPlugin(), + ], + undefined, + 'index-styled.js', + { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + } + ); + + webpackCompile(config, (csps) => { + const expected = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-IDmpTcnLo5Niek0rbHm9EEQtYiqYHApvDU+Rta9RdVU=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-bFK7QzTObijstzDDaq2yN82QIYcoYx/EDD87NWCGiPw=' 'nonce-mockedbase64string-3' 'nonce-mockedbase64string-4'"; + + expect(csps['index.html']).toEqual(expected); + done(); + }); + }); + it('inserts a custom policy if one is defined', (done) => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ diff --git a/test-utils/fixtures/external-scripts-styles.html b/test-utils/fixtures/external-scripts-styles.html new file mode 100644 index 0000000..106ffd1 --- /dev/null +++ b/test-utils/fixtures/external-scripts-styles.html @@ -0,0 +1,13 @@ + + + + + Slack CSP HTML Webpack Plugin Tests + + + + + +Body + + diff --git a/test-utils/fixtures/index-styled.js b/test-utils/fixtures/index-styled.js new file mode 100644 index 0000000..89027e9 --- /dev/null +++ b/test-utils/fixtures/index-styled.js @@ -0,0 +1 @@ +require('./index.css'); diff --git a/test-utils/fixtures/index.css b/test-utils/fixtures/index.css new file mode 100644 index 0000000..60f1eab --- /dev/null +++ b/test-utils/fixtures/index.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/test-utils/webpack-helpers.js b/test-utils/webpack-helpers.js index bcf0867..5ad51ad 100644 --- a/test-utils/webpack-helpers.js +++ b/test-utils/webpack-helpers.js @@ -74,16 +74,22 @@ function webpackCompile( * @param {string} publicPath - publicPath setting for webpack * @return {{mode: string, output: {path: string, filename: string}, entry: string, plugins: *}} */ -function createWebpackConfig(plugins, publicPath = undefined) { +function createWebpackConfig( + plugins, + publicPath = undefined, + entry = 'index.js', + extra = {} +) { return { mode: 'none', - entry: path.join(__dirname, '..', 'test-utils', 'fixtures', 'index.js'), + entry: path.join(__dirname, '..', 'test-utils', 'fixtures', entry), output: { path: WEBPACK_OUTPUT_DIR, publicPath, filename: 'index.bundle.js', }, plugins, + ...extra, }; } From df3f1c953b9617bceddc005e70755b1133eed2fb Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Fri, 7 May 2021 14:18:32 +0100 Subject: [PATCH 4/8] Add comments per review --- plugin.js | 6 ++++-- test-utils/webpack-helpers.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugin.js b/plugin.js index 527b9c1..fd8aa21 100644 --- a/plugin.js +++ b/plugin.js @@ -324,14 +324,15 @@ class CspHtmlWebpackPlugin { return compileCb(null, htmlPluginData); } - // get all nonces for script and style tags + // get all nonces for linked script and style tags const scriptNonce = this.setNonce($, 'script-src', 'script[src]'); const styleNonce = this.setNonce($, 'style-src', 'link[rel="stylesheet"]'); - // get all shas for script and style tags + // get all shas for inline script and style tags const scriptShas = this.getShas($, 'script-src', 'script:not([src])'); const styleShas = this.getShas($, 'style-src', 'style:not([href])'); + // find scripts and styles that were linked to in this HtmlWebpackPlugin instance's output const includedScripts = $('script[src]') .map((i, element) => $(element).attr('src')) .get(); @@ -339,6 +340,7 @@ class CspHtmlWebpackPlugin { .map((i, element) => $(element).attr('href')) .get(); + // get all the shas for scripts and styles generated and linked to by this HtmlWebpackPlugin instance const linkedScriptShas = this.scriptFilesToHash .filter((filename) => includedScripts.includes(path.join(this.publicPath, filename)) diff --git a/test-utils/webpack-helpers.js b/test-utils/webpack-helpers.js index 5ad51ad..604600b 100644 --- a/test-utils/webpack-helpers.js +++ b/test-utils/webpack-helpers.js @@ -72,13 +72,15 @@ function webpackCompile( * Helper to create a basic webpack config which can then be used in the compile function * @param plugins[] - array of plugins to pass into webpack * @param {string} publicPath - publicPath setting for webpack + * @param {string} entry - filename of the entrypoint to use + * @param {Object} extraWebpackConfig - extra config to pass to webpack * @return {{mode: string, output: {path: string, filename: string}, entry: string, plugins: *}} */ function createWebpackConfig( plugins, publicPath = undefined, entry = 'index.js', - extra = {} + extraWebpackConfig = {} ) { return { mode: 'none', @@ -89,7 +91,7 @@ function createWebpackConfig( filename: 'index.bundle.js', }, plugins, - ...extra, + ...extraWebpackConfig, }; } From 8bb9860b88424092de4321fe8c4c79f1ecb8574a Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Fri, 7 May 2021 21:53:15 +0100 Subject: [PATCH 5/8] Add test for multiple HtmlWebpackPlugin instances per review --- plugin.jest.js | 66 ++++++++++++++++++++++++++++++++++ test-utils/fixtures/index-1.js | 3 ++ test-utils/fixtures/index-2.js | 3 ++ 3 files changed, 72 insertions(+) create mode 100644 test-utils/fixtures/index-1.js create mode 100644 test-utils/fixtures/index-2.js diff --git a/plugin.jest.js b/plugin.jest.js index 5585f0a..683e502 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -186,6 +186,72 @@ describe('CspHtmlWebpackPlugin', () => { }); }); + it('only inserts hashes for linked scripts and styles from the same HtmlWebpackPlugin instance', (done) => { + const config = createWebpackConfig( + [ + new HtmlWebpackPlugin({ + filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'), + template: path.join( + __dirname, + 'test-utils', + 'fixtures', + 'external-scripts-styles.html' + ), + chunks: ['1'], + }), + new HtmlWebpackPlugin({ + filename: path.join(WEBPACK_OUTPUT_DIR, 'index-2.html'), + template: path.join( + __dirname, + 'test-utils', + 'fixtures', + 'external-scripts-styles.html' + ), + chunks: ['2'], + }), + new MiniCssExtractPlugin(), + new CspHtmlWebpackPlugin(), + ], + undefined, + undefined, + { + entry: { + '1': path.join(__dirname, 'test-utils', 'fixtures', 'index-1.js'), + '2': path.join(__dirname, 'test-utils', 'fixtures', 'index-2.js'), + }, + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + output: { + path: WEBPACK_OUTPUT_DIR, + filename: 'index-[name].bundle.js', + }, + } + ); + + webpackCompile(config, (csps) => { + const expected1 = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-Y3RBVJzjgMLd/3xbsXMQc/ZEfadYzG3ndisG/ogf+jQ=' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-3'"; + const expected2 = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha256-npoLW6kyIiQHrDdOzxWCi7oMbea1fUsMVFlclhuByTY=' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-6'"; + + expect(csps['index-1.html']).toEqual(expected1); + expect(csps['index-2.html']).toEqual(expected2); + done(); + }); + }); + it('inserts a custom policy if one is defined', (done) => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ diff --git a/test-utils/fixtures/index-1.js b/test-utils/fixtures/index-1.js new file mode 100644 index 0000000..35a9af4 --- /dev/null +++ b/test-utils/fixtures/index-1.js @@ -0,0 +1,3 @@ +require('./common'); + +document.body.innerHTML += '

index-1.js

'; diff --git a/test-utils/fixtures/index-2.js b/test-utils/fixtures/index-2.js new file mode 100644 index 0000000..7f12f9d --- /dev/null +++ b/test-utils/fixtures/index-2.js @@ -0,0 +1,3 @@ +require('./common'); + +document.body.innerHTML += '

index-2.js

'; From ccce97bd0f50be650e4aae410f94ea8000eda678 Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Fri, 7 May 2021 22:49:16 +0100 Subject: [PATCH 6/8] Add integrity attribute when generating hashes for linked scripts/styles --- plugin.jest.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++ plugin.js | 63 +++++++++++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/plugin.jest.js b/plugin.jest.js index 683e502..e9748aa 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -543,6 +543,102 @@ describe('CspHtmlWebpackPlugin', () => { }); }); + describe('Adding integrity attribute', () => { + it('adds an integrity attribute to linked scripts and styles', (done) => { + const config = createWebpackConfig( + [ + new HtmlWebpackPlugin({ + filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), + template: path.join( + __dirname, + 'test-utils', + 'fixtures', + 'external-scripts-styles.html' + ), + }), + new MiniCssExtractPlugin(), + new CspHtmlWebpackPlugin(), + ], + undefined, + 'index-styled.js', + { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + } + ); + + webpackCompile(config, (_, html) => { + const scripts = html['index.html']('script[src]'); + const styles = html['index.html']('link[rel="stylesheet"]'); + + scripts.each((i, script) => { + if (!script.attribs.src.startsWith('http')) { + expect(script.attribs.integrity).toEqual("sha256-IDmpTcnLo5Niek0rbHm9EEQtYiqYHApvDU+Rta9RdVU="); + } else { + expect(script.attribs.integrity).toBeUndefined(); + } + }) + styles.each((i, style) => { + if (!style.attribs.href.startsWith('http')) { + expect(style.attribs.integrity).toEqual("sha256-bFK7QzTObijstzDDaq2yN82QIYcoYx/EDD87NWCGiPw="); + } else { + expect(style.attribs.integrity).toBeUndefined(); + } + }); + done(); + }); + }); + + it('does not add an integrity attribute to inline scripts or styles', (done) => { + const config = createWebpackConfig( + [ + new HtmlWebpackPlugin({ + filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), + template: path.join( + __dirname, + 'test-utils', + 'fixtures', + 'with-script-and-style.html' + ), + }), + new MiniCssExtractPlugin(), + new CspHtmlWebpackPlugin(), + ], + undefined, + 'index-styled.js', + { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + } + ); + + webpackCompile(config, (_, html) => { + const scripts = html['index.html']('script:not([src])'); + const styles = html['index.html']('style'); + + scripts.each((i, script) => { + expect(script.attribs.integrity).toBeUndefined(); + }) + styles.each((i, style) => { + expect(style.attribs.integrity).toBeUndefined(); + }); + done(); + }); + }); + }); + describe('Hash / Nonce enabled check', () => { it("doesn't add hashes to any policy rule if that policy rule has been globally disabled", (done) => { const config = createWebpackConfig([ diff --git a/plugin.js b/plugin.js index fd8aa21..68359d9 100644 --- a/plugin.js +++ b/plugin.js @@ -81,6 +81,9 @@ class CspHtmlWebpackPlugin { // the additional options that this plugin allows this.opts = Object.freeze({ ...defaultAdditionalOpts, ...additionalOpts }); + // the calculated hashes for each file, indexed by filename + this.hashes = {}; + // valid hashes from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Sources if (!['sha256', 'sha384', 'sha512'].includes(this.opts.hashingMethod)) { throw new Error( @@ -262,6 +265,19 @@ class CspHtmlWebpackPlugin { return `'${this.opts.hashingMethod}-${hashed}'`; } + /** + * Gets the hash of a file that is a webpack asset, storing the hash in a cache. + * @param assets + * @param {string} filename + * @returns {string} + */ + hashFile(assets, filename) { + if (!Object.prototype.hasOwnProperty.call(this.hashes, filename)) { + this.hashes[filename] = this.hash(assets[filename].source()); + } + return this.hashes[filename]; + } + /** * Calculates shas of the policy / selector we define * @param {object} $ - the Cheerio instance @@ -345,12 +361,12 @@ class CspHtmlWebpackPlugin { .filter((filename) => includedScripts.includes(path.join(this.publicPath, filename)) ) - .map((filename) => this.hash(compilation.assets[filename].source())); + .map((filename) => this.hashFile(compilation.assets, filename)); const linkedStyleShas = this.styleFilesToHash .filter((filename) => includedStyles.includes(path.join(this.publicPath, filename)) ) - .map((filename) => this.hash(compilation.assets[filename].source())); + .map((filename) => this.hashFile(compilation.assets, filename)); const builtPolicy = this.buildPolicy({ ...this.policy, @@ -395,6 +411,45 @@ class CspHtmlWebpackPlugin { return compileCb(null, htmlPluginData); } + /** + * Remove the public path from a URL, if present + * @param publicPath + * @param {string} path + * @returns {string} + */ + getFilename(publicPath, path) { + if (!publicPath || !path.startsWith(publicPath)) { + return path; + } + return path.substr(publicPath.length); + } + + /** + * Add integrity attributes to asset tags + * @param compilation + * @param htmlPluginData + * @param compileCb + */ + addIntegrityAttributes(compilation, htmlPluginData, compileCb) { + if (this.hashEnabled['script-src'] !== false) { + htmlPluginData.assetTags.scripts.filter(tag => tag.attributes.src).forEach(tag => { + const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.src); + if (filename in compilation.assets) { + tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1); + } + }); + } + if (this.hashEnabled['style-src'] !== false) { + htmlPluginData.assetTags.styles.filter(tag => tag.attributes.href).forEach(tag => { + const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.href); + if (filename in compilation.assets) { + tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1); + } + }); + } + return compileCb(null, htmlPluginData); + } + /** * Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template * @param compiler @@ -413,6 +468,10 @@ class CspHtmlWebpackPlugin { 'CspHtmlWebpackPlugin', this.getFilesToHash.bind(this) ); + HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync( + 'CspHtmlWebpackPlugin', + this.addIntegrityAttributes.bind(this, compilation) + ) }); } } From 597e4d170d523a9d960850f8f1c47f79a2f8407a Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Fri, 7 May 2021 23:17:29 +0100 Subject: [PATCH 7/8] Fix ESLint errors --- plugin.jest.js | 16 +++++++----- plugin.js | 70 +++++++++++++++++++++++++++++++------------------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/plugin.jest.js b/plugin.jest.js index e9748aa..4f402c2 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -216,8 +216,8 @@ describe('CspHtmlWebpackPlugin', () => { undefined, { entry: { - '1': path.join(__dirname, 'test-utils', 'fixtures', 'index-1.js'), - '2': path.join(__dirname, 'test-utils', 'fixtures', 'index-2.js'), + 1: path.join(__dirname, 'test-utils', 'fixtures', 'index-1.js'), + 2: path.join(__dirname, 'test-utils', 'fixtures', 'index-2.js'), }, module: { rules: [ @@ -579,14 +579,18 @@ describe('CspHtmlWebpackPlugin', () => { scripts.each((i, script) => { if (!script.attribs.src.startsWith('http')) { - expect(script.attribs.integrity).toEqual("sha256-IDmpTcnLo5Niek0rbHm9EEQtYiqYHApvDU+Rta9RdVU="); + expect(script.attribs.integrity).toEqual( + 'sha256-IDmpTcnLo5Niek0rbHm9EEQtYiqYHApvDU+Rta9RdVU=' + ); } else { expect(script.attribs.integrity).toBeUndefined(); } - }) + }); styles.each((i, style) => { if (!style.attribs.href.startsWith('http')) { - expect(style.attribs.integrity).toEqual("sha256-bFK7QzTObijstzDDaq2yN82QIYcoYx/EDD87NWCGiPw="); + expect(style.attribs.integrity).toEqual( + 'sha256-bFK7QzTObijstzDDaq2yN82QIYcoYx/EDD87NWCGiPw=' + ); } else { expect(style.attribs.integrity).toBeUndefined(); } @@ -630,7 +634,7 @@ describe('CspHtmlWebpackPlugin', () => { scripts.each((i, script) => { expect(script.attribs.integrity).toBeUndefined(); - }) + }); styles.each((i, style) => { expect(style.attribs.integrity).toBeUndefined(); }); diff --git a/plugin.js b/plugin.js index 68359d9..05b3a70 100644 --- a/plugin.js +++ b/plugin.js @@ -20,6 +20,19 @@ try { } } +/** + * Remove the public path from a URL, if present + * @param publicPath + * @param {string} filePath + * @returns {string} + */ +const getFilename = (publicPath, filePath) => { + if (!publicPath || !filePath.startsWith(publicPath)) { + return filePath; + } + return filePath.substr(publicPath.length); +}; + /** * The default function for adding the CSP to the head of a document * Can be overwritten to allow the developer to process the CSP in their own way @@ -411,19 +424,6 @@ class CspHtmlWebpackPlugin { return compileCb(null, htmlPluginData); } - /** - * Remove the public path from a URL, if present - * @param publicPath - * @param {string} path - * @returns {string} - */ - getFilename(publicPath, path) { - if (!publicPath || !path.startsWith(publicPath)) { - return path; - } - return path.substr(publicPath.length); - } - /** * Add integrity attributes to asset tags * @param compilation @@ -432,20 +432,38 @@ class CspHtmlWebpackPlugin { */ addIntegrityAttributes(compilation, htmlPluginData, compileCb) { if (this.hashEnabled['script-src'] !== false) { - htmlPluginData.assetTags.scripts.filter(tag => tag.attributes.src).forEach(tag => { - const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.src); - if (filename in compilation.assets) { - tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1); - } - }); + htmlPluginData.assetTags.scripts + .filter((tag) => tag.attributes.src) + .forEach((tag) => { + const filename = getFilename( + compilation.options.output.publicPath, + tag.attributes.src + ); + if (filename in compilation.assets) { + // eslint-disable-next-line no-param-reassign + tag.attributes.integrity = this.hashFile( + compilation.assets, + filename + ).slice(1, -1); + } + }); } if (this.hashEnabled['style-src'] !== false) { - htmlPluginData.assetTags.styles.filter(tag => tag.attributes.href).forEach(tag => { - const filename = this.getFilename(compilation.options.output.publicPath, tag.attributes.href); - if (filename in compilation.assets) { - tag.attributes.integrity = this.hashFile(compilation.assets, filename).slice(1, -1); - } - }); + htmlPluginData.assetTags.styles + .filter((tag) => tag.attributes.href) + .forEach((tag) => { + const filename = getFilename( + compilation.options.output.publicPath, + tag.attributes.href + ); + if (filename in compilation.assets) { + // eslint-disable-next-line no-param-reassign + tag.attributes.integrity = this.hashFile( + compilation.assets, + filename + ).slice(1, -1); + } + }); } return compileCb(null, htmlPluginData); } @@ -471,7 +489,7 @@ class CspHtmlWebpackPlugin { HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync( 'CspHtmlWebpackPlugin', this.addIntegrityAttributes.bind(this, compilation) - ) + ); }); } } From bbc072abcb3d1b8c3873d5f3bac7ad928f3b34d8 Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Tue, 11 May 2021 21:31:02 +0100 Subject: [PATCH 8/8] Do not add integrity attributes if the entire plugin is disabled --- plugin.jest.js | 9 +++++++++ plugin.js | 3 +++ 2 files changed, 12 insertions(+) diff --git a/plugin.jest.js b/plugin.jest.js index 4f402c2..eeba286 100644 --- a/plugin.jest.js +++ b/plugin.jest.js @@ -872,6 +872,7 @@ describe('CspHtmlWebpackPlugin', () => { webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); + expect(selectors['index.html']('[integrity]').length).toEqual(0); done(); }); }); @@ -896,6 +897,7 @@ describe('CspHtmlWebpackPlugin', () => { webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); + expect(selectors['index.html']('[integrity]').length).toEqual(0); done(); }); }); @@ -922,6 +924,7 @@ describe('CspHtmlWebpackPlugin', () => { webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); + expect(selectors['index.html']('[integrity]').length).toEqual(0); done(); }); }); @@ -957,6 +960,12 @@ describe('CspHtmlWebpackPlugin', () => { expect(csps['index-disabled.html']).toBeUndefined(); expect(selectors['index-enabled.html']('meta').length).toEqual(2); expect(selectors['index-disabled.html']('meta').length).toEqual(1); + expect(selectors['index-enabled.html']('[integrity]').length).toEqual( + 1 + ); + expect(selectors['index-disabled.html']('[integrity]').length).toEqual( + 0 + ); done(); }); }); diff --git a/plugin.js b/plugin.js index 05b3a70..4d03057 100644 --- a/plugin.js +++ b/plugin.js @@ -431,6 +431,9 @@ class CspHtmlWebpackPlugin { * @param compileCb */ addIntegrityAttributes(compilation, htmlPluginData, compileCb) { + if (!this.isEnabled(htmlPluginData)) { + return compileCb(null, htmlPluginData); + } if (this.hashEnabled['script-src'] !== false) { htmlPluginData.assetTags.scripts .filter((tag) => tag.attributes.src)