diff --git a/plugin.jest.js b/plugin.jest.js index bc0fe72..786c2a2 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, @@ -35,6 +36,10 @@ describe('CspHtmlWebpackPlugin', () => { .mockImplementationOnce(() => 'mockedbase64string-4') .mockImplementationOnce(() => 'mockedbase64string-5') .mockImplementationOnce(() => 'mockedbase64string-6') + .mockImplementationOnce(() => 'mockedbase64string-7') + .mockImplementationOnce(() => 'mockedbase64string-8') + .mockImplementationOnce(() => 'mockedbase64string-9') + .mockImplementationOnce(() => 'mockedbase64string-10') .mockImplementation( () => new Error('Need to add more crypto.randomBytes mocks') ); @@ -66,7 +71,7 @@ describe('CspHtmlWebpackPlugin', () => { 'strict-dynamic', 'report-sample', ].forEach((source) => { - it(`throws an error if '${source}' is not wrapped in apostrophes in an array defined policy`, (done) => { + it(`throws an error if '${source}' is not wrapped in apostrophes in an array defined policy`, () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -85,7 +90,7 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile( + return webpackCompile( config, (_1, _2, _3, errors) => { expect(errors[0]).toEqual( @@ -93,7 +98,6 @@ describe('CspHtmlWebpackPlugin', () => { `CSP: policy for script-src contains ${source} which should be wrapped in apostrophes` ) ); - done(); }, { expectError: true, @@ -101,7 +105,7 @@ describe('CspHtmlWebpackPlugin', () => { ); }); - it(`throws an error if '${source}' is not wrapped in apostrophes in a string defined policy`, (done) => { + it(`throws an error if '${source}' is not wrapped in apostrophes in a string defined policy`, () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -120,7 +124,7 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile( + return webpackCompile( config, (_1, _2, _3, errors) => { expect(errors[0]).toEqual( @@ -128,7 +132,6 @@ describe('CspHtmlWebpackPlugin', () => { `CSP: policy for script-src contains ${source} which should be wrapped in apostrophes` ) ); - done(); }, { expectError: true, @@ -140,7 +143,7 @@ describe('CspHtmlWebpackPlugin', () => { }); describe('Adding sha and nonce checksums', () => { - it('inserts the default policy, including sha-256 hashes of other inline scripts and styles found, and nonce hashes of external scripts found', (done) => { + it('inserts the default policy, including sha-256 hashes of other inline scripts and styles found, and nonce hashes of external scripts found', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -154,19 +157,126 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('inserts a custom policy if one is defined', (done) => { + it('inserts hashes for linked scripts and styles from the same Webpack build', () => { + 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'], + }, + ], + }, + } + ); + // the following setting is required for SRI to work: + config.output.crossOriginLoading = 'anonymous'; + + return webpackCompile(config, (csps) => { + const expected = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-WgET5XoAJGc0It6r8VkXSFPWq9s8fkK6gkP+veIwTd3X+jBr00Jqir+6n2anN3T4' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2' 'nonce-mockedbase64string-3';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-ePgPKVdofu2Id6+vq//vnkON0KolpiwbPbJuiALXh1vTb/dXtC/WjAbgcrL5N1wz' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5' 'nonce-mockedbase64string-6' 'nonce-primereact-nonce'"; + + expect(csps['index.html']).toEqual(expected); + }); + }); + + it('only inserts hashes for linked scripts and styles from the same HtmlWebpackPlugin instance', () => { + 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', + crossOriginLoading: 'anonymous', + }, + } + ); + + return webpackCompile(config, (csps) => { + const expected1 = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-MNBsDd86ojq/E2ui0CRqhF7X8jLUhjXV09NVZ6oqeq5r0ZHH9345GYhftO9U8yfA' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2' 'nonce-mockedbase64string-3';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5' 'nonce-primereact-nonce'"; + const expected2 = + "base-uri 'self';" + + " object-src 'none';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-LF8cxUorWv/F9Ftzm+e8te0dhz9zILBuNuJQUQwDdPJopBzXiSUakOVQ+qEnd3yx' 'nonce-mockedbase64string-6' 'nonce-mockedbase64string-7' 'nonce-mockedbase64string-8';" + + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-mockedbase64string-9' 'nonce-mockedbase64string-10' 'nonce-primereact-nonce'"; + + expect(csps['index-1.html']).toEqual(expected1); + expect(csps['index-2.html']).toEqual(expected2); + }); + }); + + it('inserts a custom policy if one is defined', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -189,21 +299,20 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected = "base-uri 'self' https://slack.com;" + " object-src 'none';" + - " script-src 'self' 'nonce-mockedbase64string-1';" + + " script-src 'self' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'self' 'nonce-primereact-nonce';" + " font-src 'self' 'https://a-slack-edge.com';" + " connect-src 'self'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('handles string values for policies where hashes and nonces are appended', (done) => { + it('handles string values for policies where hashes and nonces are appended', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -223,19 +332,18 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected = "base-uri 'self';" + " object-src 'none';" + - " script-src 'self' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'self' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'self' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it("doesn't add nonces for scripts / styles generated where their host has already been defined in the CSP, and 'strict-dynamic' doesn't exist in the policy", (done) => { + it("doesn't add nonces for scripts / styles generated where their host has already been defined in the CSP, and 'strict-dynamic' doesn't exist in the policy", () => { const config = createWebpackConfig( [ new HtmlWebpackPlugin({ @@ -258,7 +366,7 @@ describe('CspHtmlWebpackPlugin', () => { 'https://my.cdn.com/' ); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { const $ = selectors['index.html']; const expected = "base-uri 'self';" + @@ -283,12 +391,10 @@ describe('CspHtmlWebpackPlugin', () => { 'https://my.cdn.com/index.bundle.js' ); expect(Object.keys($('script')[2].attribs)).not.toContain('nonce'); - - done(); }); }); - it("continues to add nonces to scripts / styles even if the host has already been whitelisted due to 'strict-dynamic' existing in the policy", (done) => { + it("continues to add nonces to scripts / styles even if the host has already been whitelisted due to 'strict-dynamic' existing in the policy", () => { const config = createWebpackConfig( [ new HtmlWebpackPlugin({ @@ -315,7 +421,7 @@ describe('CspHtmlWebpackPlugin', () => { 'https://my.cdn.com/' ); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { const $ = selectors['index.html']; // 'strict-dynamic' should be at the end of the script-src here @@ -342,13 +448,11 @@ describe('CspHtmlWebpackPlugin', () => { 'https://my.cdn.com/index.bundle.js' ); expect($('script')[2].attribs.nonce).toEqual('mockedbase64string-2'); - - done(); }); }); describe('HtmlWebpackPlugin defined policy', () => { - it('inserts a custom policy from a specific HtmlWebpackPlugin instance, if one is defined', (done) => { + it('inserts a custom policy from a specific HtmlWebpackPlugin instance, if one is defined', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -371,21 +475,20 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected = "base-uri 'self' https://slack.com;" + " object-src 'none';" + - " script-src 'self' 'nonce-mockedbase64string-1';" + + " script-src 'self' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'self' 'nonce-primereact-nonce';" + " font-src 'self' 'https://a-slack-edge.com';" + " connect-src 'self'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('merges and overwrites policies, with a html webpack plugin instance policy taking precedence, followed by the csp instance, and then the default policy', (done) => { + it('merges and overwrites policies, with a html webpack plugin instance policy taking precedence, followed by the csp instance, and then the default policy', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -413,20 +516,19 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + // this comes from the default policy " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-primereact-nonce';" + // 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 expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('only adds a custom policy to the html file which has a policy defined; uses the default policy for any others', (done) => { + it('only adds a custom policy to the html file which has a policy defined; uses the default policy for any others', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-csp.html'), @@ -455,29 +557,131 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'https://b-slack-edge.com' 'nonce-primereact-nonce'"; 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-primereact-nonce'"; expect(csps['index-csp.html']).toEqual(expectedCustom); expect(csps['index-no-csp.html']).toEqual(expectedDefault); - done(); + }); + }); + }); + }); + + describe('Adding integrity attribute', () => { + it('adds an integrity attribute to linked scripts and styles', () => { + 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'], + }, + ], + }, + } + ); + // the following setting is required for SRI to work: + config.output.crossOriginLoading = 'anonymous'; + + return 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( + 'sha384-WgET5XoAJGc0It6r8VkXSFPWq9s8fkK6gkP+veIwTd3X+jBr00Jqir+6n2anN3T4' + ); + } else { + expect(script.attribs.integrity).toBeUndefined(); + } + }); + styles.each((i, style) => { + if (!style.attribs.href.startsWith('http')) { + expect(style.attribs.integrity).toEqual( + 'sha384-ePgPKVdofu2Id6+vq//vnkON0KolpiwbPbJuiALXh1vTb/dXtC/WjAbgcrL5N1wz' + ); + } else { + expect(style.attribs.integrity).toBeUndefined(); + } + }); + }); + }); + + it('does not add an integrity attribute to inline scripts or styles', () => { + 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'], + }, + ], + }, + } + ); + + // the following setting is required for SRI to work: + config.output.crossOriginLoading = 'anonymous'; + + return 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(); }); }); }); }); describe('Hash / Nonce enabled check', () => { - it("doesn't add hashes to any policy rule if that policy rule has been globally disabled", (done) => { + it("doesn't add hashes to any policy rule if that policy rule has been globally disabled", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'), @@ -509,7 +713,7 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected1 = "base-uri 'self';" + " object-src 'none';" + @@ -525,12 +729,10 @@ describe('CspHtmlWebpackPlugin', () => { // no hashes in either one of the script-src or style-src policies expect(csps['index-1.html']).toEqual(expected1); expect(csps['index-2.html']).toEqual(expected2); - - done(); }); }); - it("doesn't add nonces to any policy rule if that policy rule has been globally disabled", (done) => { + it("doesn't add nonces to any policy rule if that policy rule has been globally disabled", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-1.html'), @@ -562,28 +764,26 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expected1 = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-primereact-nonce'"; const expected2 = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-primereact-nonce'"; // no nonces in either one of the script-src or style-src policies expect(csps['index-1.html']).toEqual(expected1); expect(csps['index-2.html']).toEqual(expected2); - - done(); }); }); - it("doesn't add hashes to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", (done) => { + it("doesn't add hashes to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-hashes.html'), @@ -612,7 +812,7 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expectedNoHashes = "base-uri 'self';" + " object-src 'none';" + @@ -622,18 +822,16 @@ describe('CspHtmlWebpackPlugin', () => { const expectedHashes = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-6' 'nonce-primereact-nonce'"; // no hashes in index-no-hashes script-src or style-src policies expect(csps['index-no-hashes.html']).toEqual(expectedNoHashes); expect(csps['index-hashes.html']).toEqual(expectedHashes); - - done(); }); }); - it("doesn't add nonces to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", (done) => { + it("doesn't add nonces to a specific policy rule if that policy rule has been disabled for that instance of HtmlWebpackPlugin", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-no-nonce.html'), @@ -662,30 +860,28 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { const expectedNoNonce = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-primereact-nonce'"; const expectedNonce = "base-uri 'self';" + " object-src 'none';" + - " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + + " script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'"; // no nonce in index-no-nonce script-src or style-src policies expect(csps['index-no-nonce.html']).toEqual(expectedNoNonce); expect(csps['index-nonce.html']).toEqual(expectedNonce); - - done(); }); }); }); describe('Plugin enabled check', () => { - it("doesn't modify the html if enabled is the bool false", (done) => { + it("doesn't modify the html if enabled is the bool false", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -704,14 +900,16 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); - done(); + expect(selectors['index.html']('[integrity]').length).toEqual(0); + expect(selectors['index.html']('[integrity]').length).toEqual(0); + expect(selectors['index.html']('[integrity]').length).toEqual(0); }); }); - it("doesn't modify the html if the `cspPlugin.enabled` option in HtmlWebpack Plugin is false", (done) => { + it("doesn't modify the html if the `cspPlugin.enabled` option in HtmlWebpack Plugin is false", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -728,14 +926,13 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); - done(); }); }); - it("doesn't modify the html if enabled is a function which return false", (done) => { + it("doesn't modify the html if enabled is a function which return false", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -755,14 +952,13 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { expect(csps['index.html']).toBeUndefined(); expect(selectors['index.html']('meta').length).toEqual(1); - done(); }); }); - it("doesn't modify html from the HtmlWebpackPlugin instance which has been disabled", (done) => { + it("doesn't modify html from the HtmlWebpackPlugin instance which has been disabled", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index-enabled.html'), @@ -788,18 +984,23 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { expect(csps['index-enabled.html']).toBeDefined(); expect(csps['index-disabled.html']).toBeUndefined(); expect(selectors['index-enabled.html']('meta').length).toEqual(2); expect(selectors['index-disabled.html']('meta').length).toEqual(1); - done(); + expect(selectors['index-enabled.html']('[integrity]').length).toEqual( + 1 + ); + expect(selectors['index-disabled.html']('[integrity]').length).toEqual( + 0 + ); }); }); }); describe('Meta tag', () => { - it('still adds the CSP policy into the CSP meta tag even if the content attribute is missing', (done) => { + it('still adds the CSP policy into the CSP meta tag even if the content attribute is missing', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -813,19 +1014,18 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('adds meta tag with completed policy when no meta tag is specified', (done) => { + it('adds meta tag with completed policy when no meta tag is specified', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -839,19 +1039,18 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it('adds meta tag with completed policy when no template is specified', (done) => { + it('adds meta tag with completed policy when no template is specified', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -859,19 +1058,18 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - done(); }); }); - it("adds the meta tag as the top most meta tag to ensure that the CSP is defined before we try loading any other scripts, if it doesn't exist", (done) => { + it("adds the meta tag as the top most meta tag to ensure that the CSP is defined before we try loading any other scripts, if it doesn't exist", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -885,23 +1083,21 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps, selectors) => { + return webpackCompile(config, (csps, selectors) => { const $ = selectors['index.html']; const metaTags = $('meta'); expect(metaTags[0].attribs['http-equiv']).toEqual( 'Content-Security-Policy' ); - - done(); }); }); }); describe('Custom process function', () => { - it('Allows the process function to be overwritten', (done) => { + it('Allows the process function to be overwritten', () => { const processFn = jest.fn(); - const builtPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; + const builtPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -922,7 +1118,7 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { // we've overwritten the default processFn, which writes the policy into the html file // so it won't exist in this object anymore. expect(csps['index.html']).toBeUndefined(); @@ -934,15 +1130,13 @@ describe('CspHtmlWebpackPlugin', () => { expect.anything(), expect.anything() ); - - done(); }); }); - it('only overwrites the processFn for the HtmlWebpackInstance where it has been defined', (done) => { + it('only overwrites the processFn for the HtmlWebpackInstance where it has been defined', () => { const processFn = jest.fn(); - const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; - const index2BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-6' 'nonce-primereact-nonce'`; + const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; + const index2BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-4' 'nonce-mockedbase64string-5'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-6' 'nonce-primereact-nonce'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -969,7 +1163,7 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { // it won't exist in the html file since we overwrote processFn expect(csps['index-1.html']).toBeUndefined(); // processFn wasn't overwritten here, so this should be added to the html file as normal @@ -982,12 +1176,10 @@ describe('CspHtmlWebpackPlugin', () => { expect.anything(), expect.anything() ); - - done(); }); }); - it('Allows to generate a file containing the policy', (done) => { + it('Allows to generate a file containing the policy', () => { function generateCSPFile( builtPolicy, _htmlPluginData, @@ -996,7 +1188,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' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; + const index1BuiltPolicy = `base-uri 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-I8j99RwEV9SFO6EKWmKLpw3VxsvfabPoUJPZMFL1WWGjVShwX4YDWuJfq5+077jO' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1' 'nonce-mockedbase64string-2'; style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-3P+ddXxfmvvtbEUrdZKBMTjmKpirnUElgB2vlkVZ4l6LCQYHCIyFMLp+OKTIR6ob' 'nonce-mockedbase64string-3' 'nonce-primereact-nonce'`; const config = createWebpackConfig([ new HtmlWebpackPlugin({ @@ -1017,7 +1209,7 @@ describe('CspHtmlWebpackPlugin', () => { ), ]); - webpackCompile(config, (csps, selectors, fileSystem) => { + return webpackCompile(config, (csps, selectors, fileSystem) => { const cspFileContent = fileSystem .readFileSync(path.join(WEBPACK_OUTPUT_DIR, 'csp.conf'), 'utf8') .toString(); @@ -1027,14 +1219,12 @@ describe('CspHtmlWebpackPlugin', () => { // A file has been generated expect(cspFileContent).toEqual(index1BuiltPolicy); - - done(); }); }); }); describe('HTML parsing', () => { - it("doesn't encode escaped HTML entities", (done) => { + it("doesn't encode escaped HTML entities", () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -1048,16 +1238,15 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (_, selectors) => { + return webpackCompile(config, (_, selectors) => { const $ = selectors['index.html']; expect($('body').html().trim()).toEqual( '<h1>Escaped Content<h1>' ); - done(); }); }); - it('generates a hash for style tags wrapped in noscript tags', (done) => { + it('generates a hash for style tags wrapped in noscript tags', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -1071,20 +1260,18 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps) => { + return webpackCompile(config, (csps) => { 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' 'sha384-rGumVytQRHlFeUsbLx6mhENgPUXD3Vs9nl5eV91pTDa+fYTdj7pa8SEoS7lKrmRe' 'nonce-mockedbase64string-1';" + " style-src 'unsafe-inline' 'self' 'unsafe-eval' 'sha384-cgYi6eN32TaTz3QrlHQ6sNzjd+bVpCL/DE1qzIF7vnAq1RbzHcrvDE9Cpvwk09cG' 'nonce-primereact-nonce'"; expect(csps['index.html']).toEqual(expected); - - done(); }); }); - it('honors xhtml mode if set on the html-webpack-plugin instance', (done) => { + it('honors xhtml mode if set on the html-webpack-plugin instance', () => { const config = createWebpackConfig([ new HtmlWebpackPlugin({ filename: path.join(WEBPACK_OUTPUT_DIR, 'index.html'), @@ -1099,7 +1286,7 @@ describe('CspHtmlWebpackPlugin', () => { new CspHtmlWebpackPlugin({}, testOptions), ]); - webpackCompile(config, (csps, selectors, fileSystem) => { + return webpackCompile(config, (csps, selectors, fileSystem) => { const xhtmlContents = fileSystem .readFileSync(path.join(WEBPACK_OUTPUT_DIR, 'index.html'), 'utf8') .toString(); @@ -1116,10 +1303,8 @@ describe('CspHtmlWebpackPlugin', () => { // csp has been added in expect(xhtmlContents).toContain( - `` + "" ); - - done(); }); }); }); diff --git a/plugin.js b/plugin.js index de31e21..48e5e27 100644 --- a/plugin.js +++ b/plugin.js @@ -9,6 +9,9 @@ const webpack = require('webpack'); const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity'); const InjectPlugin = require('webpack-inject-plugin').default; +// TODO: replace path usage in urls (prefix) +const path = require('path'); + /* eslint-disable no-useless-escape */ // Attempt to load HtmlWebpackPlugin@4 @@ -24,6 +27,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 @@ -91,6 +107,9 @@ class CspHtmlWebpackPlugin { // special NONCE for PrimeReact inline styles this.primeReactInlineNonce = this.createNonce(); + // 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( @@ -272,6 +291,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 @@ -334,7 +366,6 @@ 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( $, @@ -346,18 +377,40 @@ class CspHtmlWebpackPlugin { styleNonce.push(`'nonce-${this.primeReactInlineNonce}'`); } - // 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(); + const includedStyles = $('link[rel="stylesheet"]') + .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)) + ) + .map((filename) => this.hashFile(compilation.assets, filename)); + const linkedStyleShas = this.styleFilesToHash + .filter((filename) => + includedStyles.includes(path.join(this.publicPath, filename)) + ) + .map((filename) => this.hashFile(compilation.assets, filename)); + 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 ), }); @@ -367,6 +420,77 @@ 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); + } + + /** + * Add integrity attributes to asset tags + * @param compilation + * @param htmlPluginData + * @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) + .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 = 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); + } + /** * Hooks into webpack to collect assets and hash them, build the policy, and add it into our HTML template * @param compiler @@ -381,6 +505,14 @@ class CspHtmlWebpackPlugin { 'CspHtmlWebpackPlugin', this.processCsp.bind(this, compilation) ); + HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync( + 'CspHtmlWebpackPlugin', + this.getFilesToHash.bind(this) + ); + HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync( + 'CspHtmlWebpackPlugin', + this.addIntegrityAttributes.bind(this, compilation) + ); }); // special handling for PrimeReact inline styles diff --git a/test-utils/fixtures/external-scripts-styles.html b/test-utils/fixtures/external-scripts-styles.html index 106ffd1..e1ef0e4 100644 --- a/test-utils/fixtures/external-scripts-styles.html +++ b/test-utils/fixtures/external-scripts-styles.html @@ -1,5 +1,19 @@ + + + + Slack CSP HTML Webpack Plugin Tests + + + + + +Body + + + + Slack CSP HTML Webpack Plugin Tests diff --git a/test-utils/fixtures/index.css b/test-utils/fixtures/index.css index 60f1eab..a08e228 100644 --- a/test-utils/fixtures/index.css +++ b/test-utils/fixtures/index.css @@ -1,3 +1,6 @@ body { color: red; } +body { + color: red; +} diff --git a/test-utils/webpack-helpers.js b/test-utils/webpack-helpers.js index 604600b..a5e62d8 100644 --- a/test-utils/webpack-helpers.js +++ b/test-utils/webpack-helpers.js @@ -21,50 +21,84 @@ function webpackCompile( callbackFn, { fs = null, expectError = false } = {} ) { - const instance = webpack(webpackConfig); + return new Promise((resolve, reject) => { + const instance = webpack(webpackConfig); - const fileSystem = fs || new MemoryFs(); - instance.outputFileSystem = fileSystem; - instance.run((err, stats) => { - // test no error or warning - if (!expectError) { - expect(err).toBeFalsy(); - expect(stats.compilation.errors.length).toEqual(0); - expect(stats.compilation.warnings.length).toEqual(0); - } + const fileSystem = fs || new MemoryFs(); + instance.outputFileSystem = fileSystem; + instance.run((err, stats) => { + // test no error or warning + if (!expectError) { + if (err) { + reject(err); + return; + } + try { + expect(stats.compilation.errors.length).toEqual(0); + } catch (e) { + reject( + new Error( + `Webpack compilation errors: ${stats.compilation.errors.join( + '\n' + )}` + ) + ); + return; + } + try { + expect(stats.compilation.warnings.length).toEqual(0); + } catch (e) { + reject( + new Error( + `Webpack compilation warnings: ${stats.compilation.warnings.join( + '\n' + )}` + ) + ); + } + } - // file all html files and convert them into cheerio objects so they can be queried - const htmlFilesCheerio = fileSystem - .readdirSync(WEBPACK_OUTPUT_DIR) - .filter((file) => file.endsWith('.html')) - .reduce( - (obj, file) => ({ + // file all html files and convert them into cheerio objects so they can be queried + const htmlFilesCheerio = fileSystem + .readdirSync(WEBPACK_OUTPUT_DIR) + .filter((file) => file.endsWith('.html')) + .reduce( + (obj, file) => ({ + ...obj, + [file]: cheerio.load( + fileSystem + .readFileSync(path.join(WEBPACK_OUTPUT_DIR, file)) + .toString() + ), + }), + {} + ); + + // find all csps from the cheerio objects + const csps = Object.keys(htmlFilesCheerio).reduce((obj, file) => { + const $ = htmlFilesCheerio[file]; + return { ...obj, - [file]: cheerio.load( - fileSystem - .readFileSync(path.join(WEBPACK_OUTPUT_DIR, file)) - .toString() + [file]: $('meta[http-equiv="Content-Security-Policy"]').attr( + 'content' ), - }), - {} - ); - - // find all csps from the cheerio objects - const csps = Object.keys(htmlFilesCheerio).reduce((obj, file) => { - const $ = htmlFilesCheerio[file]; - return { - ...obj, - [file]: $('meta[http-equiv="Content-Security-Policy"]').attr('content'), - }; - }, {}); + }; + }, {}); - callbackFn( - csps, - htmlFilesCheerio, - fileSystem, - stats.compilation.errors, - stats.compilation.warnings - ); + try { + resolve( + callbackFn( + csps, + htmlFilesCheerio, + fileSystem, + stats.compilation.errors, + stats.compilation.warnings + ) + ); + } catch (e) { + reject(e); + } + }); }); }