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) + ) }); } }