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 3e26e0f..eeba286 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, @@ -136,7 +137,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); @@ -144,6 +145,113 @@ 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('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({ @@ -168,7 +276,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 +307,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 +445,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 +484,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 +526,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); @@ -435,6 +543,106 @@ 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([ @@ -523,13 +731,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 +787,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 +831,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 @@ -664,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(); }); }); @@ -688,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(); }); }); @@ -714,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(); }); }); @@ -749,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(); }); }); @@ -773,7 +990,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 +1016,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 +1036,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 +1074,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 +1113,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 +1168,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 +1246,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 +1287,7 @@ describe('CspHtmlWebpackPlugin', () => { // csp has been added in expect(xhtmlContents).toContain( - `` + `` ); done(); diff --git a/plugin.js b/plugin.js index 56b927c..4d03057 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 @@ -19,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 @@ -80,6 +94,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( @@ -261,6 +278,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 @@ -323,22 +353,44 @@ 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(); + 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 ), }); @@ -348,6 +400,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 @@ -362,6 +485,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) + ); }); } } 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-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

'; 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..604600b 100644 --- a/test-utils/webpack-helpers.js +++ b/test-utils/webpack-helpers.js @@ -72,18 +72,26 @@ 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) { +function createWebpackConfig( + plugins, + publicPath = undefined, + entry = 'index.js', + extraWebpackConfig = {} +) { 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, + ...extraWebpackConfig, }; }