diff --git a/README.md b/README.md index 9cc1992..343526e 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ npm install webpack-subresource-integrity --save-dev import SriPlugin from 'webpack-subresource-integrity'; const compiler = webpack({ + output: { + crossOriginLoading: 'anonymous', + }, plugins: [ new SriPlugin({ hashFuncNames: ['sha256', 'sha384'], enabled: process.env.NODE_ENV === 'production', - crossorigin: 'anonymous', }), ], }); @@ -46,8 +48,10 @@ HTML pages.) #### With HtmlWebpackPlugin When html-webpack-plugin is injecting assets into the template (the -default), the `integrity` attribute will be set automatically. There -is nothing else to be done. +default), the `integrity` attribute will be set automatically. The +`crossorigin` attribute will be set as well, to the value of +`output.crossOriginLoading` webpack option. There is nothing else to +be done. #### With HtmlWebpackPlugin({ inject: false }) @@ -60,7 +64,7 @@ template as follows: <% } %> @@ -68,7 +72,7 @@ template as follows: <% } %> @@ -88,6 +92,10 @@ compiler.plugin("done", stats => { }); ``` +Note that you're also required to set the `crossorigin` attribute. It +is recommended to set this attribute to the same value as the webpack +`output.crossOriginLoading` configuration option. + ### Options #### hashFuncNames @@ -110,19 +118,22 @@ development mode. #### crossorigin -Default value: `"anonymous"` +**DEPRECATED**. Use webpack option `output.crossOriginLoading' +instead'. + +~~Default value: `"anonymous"`~~ -When using `HtmlWebpackPlugin({ inject: true })`, this option +~~When using `HtmlWebpackPlugin({ inject: true })`, this option specifies the value to be used for the `crossorigin` attribute for -injected assets. +injected assets.~~ -The value will also be available as +~~The value will also be available as `htmlWebpackPlugin.options.sriCrossOrigin` in html-webpack-plugin -templates. +templates.~~ -See +~~See [SRI: Cross-origin data leakage](https://www.w3.org/TR/SRI/#cross-origin-data-leakage) and -[MDN: CORS settings attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes) +[MDN: CORS settings attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes)~~ ## Caveats diff --git a/index.js b/index.js index 150d647..364cf6f 100644 --- a/index.js +++ b/index.js @@ -21,13 +21,24 @@ function findDepChunks(chunk, allDepChunkIds) { }); } -function WebIntegrityJsonpMainTemplatePlugin() {} +function WebIntegrityJsonpMainTemplatePlugin(sriPlugin, compilation) { + this.sriPlugin = sriPlugin; + this.compilation = compilation; +} WebIntegrityJsonpMainTemplatePlugin.prototype.apply = function apply(mainTemplate) { + var self = this; + /* * Patch jsonp-script code to add the integrity attribute. */ mainTemplate.plugin('jsonp-script', function jsonpScriptPlugin(source) { + if (!this.outputOptions.crossOriginLoading) { + self.sriPlugin.error( + self.compilation, + 'webpack option output.crossOriginLoading not set, code splitting will not work!' + ); + } return this.asString([ source, 'script.integrity = sriHashes[chunkId];' @@ -81,8 +92,7 @@ function SubresourceIntegrityPlugin(options) { } this.options = { - enabled: true, - crossorigin: 'anonymous' + enabled: true }; for (var key in useOptions) { @@ -110,6 +120,10 @@ SubresourceIntegrityPlugin.prototype.error = function error(compilation, message }; SubresourceIntegrityPlugin.prototype.validateOptions = function validateOptions(compilation) { + if (this.optionsValidated) { + return; + } + this.optionsValidated = true; if (this.options.deprecatedOptions) { this.warnOnce( compilation, @@ -118,6 +132,11 @@ SubresourceIntegrityPlugin.prototype.validateOptions = function validateOptions( 'Please update your code. ' + 'See https://github.com/waysact/webpack-subresource-integrity/issues/18 for more information.'); } + if (this.options.enabled && !compilation.compiler.options.output.crossOriginLoading) { + this.warnOnce( + compilation, + 'Set webpack option output.crossOriginLoading when using this plugin.'); + } if (!Array.isArray(this.options.hashFuncNames)) { this.error( compilation, @@ -160,20 +179,31 @@ SubresourceIntegrityPlugin.prototype.validateOptions = function validateOptions( 'See http://www.w3.org/TR/SRI/#cryptographic-hash-functions for more information.'); } } - if (typeof this.options.crossorigin !== 'string' && - !(this.options.crossorigin instanceof String)) { - this.error( - compilation, - 'options.crossorigin must be a string.'); - this.options.enabled = false; - return; - } - if (standardCrossoriginOptions.indexOf(this.options.crossorigin) < 0) { + if (typeof this.options.crossorigin === 'undefined') { + this.options.crossorigin = + compilation.compiler.options.output.crossOriginLoading || 'anonymous'; + } else { this.warnOnce( compilation, - 'You\'ve specified a value for the crossorigin option that is not part of the set of standard values. ' + - 'These are: ' + standardCrossoriginOptions.join(', ') + '. ' + - 'See https://www.w3.org/TR/SRI/#cross-origin-data-leakage for more information.'); + 'Specifying options.crossorigin is deprecated. ' + + 'Instead, set webpack option output.crossOriginLoading. ' + + 'Support will be removed in webpack-subresource-integrity 1.0.0. ' + + 'See https://github.com/waysact/webpack-subresource-integrity/issues/20 for more information.'); + if (typeof this.options.crossorigin !== 'string' && + !(this.options.crossorigin instanceof String)) { + this.error( + compilation, + 'options.crossorigin must be a string.'); + this.options.enabled = false; + return; + } + if (standardCrossoriginOptions.indexOf(this.options.crossorigin) < 0) { + this.warnOnce( + compilation, + 'You\'ve specified a value for the crossorigin option that is not part of the set of standard values. ' + + 'These are: ' + standardCrossoriginOptions.join(', ') + '. ' + + 'See https://www.w3.org/TR/SRI/#cross-origin-data-leakage for more information.'); + } } }; @@ -204,7 +234,7 @@ SubresourceIntegrityPlugin.prototype.apply = function apply(compiler) { return; } - compilation.mainTemplate.apply(new WebIntegrityJsonpMainTemplatePlugin()); + compilation.mainTemplate.apply(new WebIntegrityJsonpMainTemplatePlugin(self, compilation)); /* * Calculate SRI values for each chunk and replace the magic @@ -328,7 +358,16 @@ SubresourceIntegrityPlugin.prototype.apply = function apply(compiler) { return compilation.assets[src].integrity; }); }); - pluginArgs.plugin.options.sriCrossOrigin = self.options.crossorigin; + Object.defineProperty( + pluginArgs.plugin.options, 'sriCrossOrigin', { + get: function get() { + self.warnOnce( + compilation, + 'htmlWebpackPlugin.options.sriCrossOrigin is deprecated, use webpackConfig.output.crossOriginLoading instead.' + ); + return self.options.crossorigin; + } + }); callback(null, pluginArgs); } diff --git a/karma.conf.js b/karma.conf.js index 09ad0a0..ec11ddb 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -42,10 +42,10 @@ function nextCreate(filesPromise, serveStaticFile, serveFile, injector, basePath response.write = function nextWrite(chunk, encoding) { var nextChunk = chunk.replace( 'src="/base/test/test.js', - 'integrity="' + toplevelScriptIntegrity + '" src="/base/test/test.js'); + 'integrity="' + toplevelScriptIntegrity + '" crossorigin="anonymous" src="/base/test/test.js'); nextChunk = nextChunk.replace( 'rel="stylesheet"', - 'rel="stylesheet" integrity="' + stylesheetIntegrity + '"' + 'rel="stylesheet" integrity="' + stylesheetIntegrity + '" crossorigin="anonymous"' ); prevWrite.call(response, nextChunk, encoding); }; @@ -82,8 +82,13 @@ module.exports = function karmaConfig(config) { 'karma-mocha' ], webpack: { + output: { + crossOriginLoading: 'anonymous' + }, plugins: [ - new SriPlugin(['sha256', 'sha384']), + new SriPlugin({ + hashFuncNames: ['sha256', 'sha384'] + }), new GetIntegrityPlugin() ], module: { diff --git a/test/chunk2.js b/test/chunk2.js index e938681..1ec94a4 100644 --- a/test/chunk2.js +++ b/test/chunk2.js @@ -4,9 +4,10 @@ module.exports = function chunk2(callback) { function forEachElement(el) { var src = el.getAttribute('src') || el.getAttribute('href'); var integrity = el.getAttribute('integrity'); + var crossorigin = el.getAttribute('crossOrigin'); if (src) { var match = src.match(/[^\/]+\.(js|css)/); - if (match && integrity && integrity.match(/^sha\d+-/)) { + if (match && crossorigin && integrity && integrity.match(/^sha\d+-/)) { resourcesWithIntegrity.push(match[0].toString()); } } diff --git a/test/index.ejs b/test/index.ejs index 7814f82..ba29622 100644 --- a/test/index.ejs +++ b/test/index.ejs @@ -1,14 +1,14 @@ <% for (var index in htmlWebpackPlugin.files.js) { %> <% } %> <% for (var index in htmlWebpackPlugin.files.css) { %> <% } %> diff --git a/test/test-webpack.js b/test/test-webpack.js index d1b2de3..da8156c 100644 --- a/test/test-webpack.js +++ b/test/test-webpack.js @@ -21,6 +21,20 @@ function createExtractTextLoader() { return ExtractTextPlugin.extract({ fallbackLoader: 'style-loader', loader: 'css-loader' }); } +function testCompilation() { + return { + warnings: [], + errors: [], + compiler: { + options: { + output: { + crossOriginLoading: 'anonymous' + } + } + } + }; +} + describe('webpack-subresource-integrity', function describe() { it('should handle circular dependencies gracefully', function it(callback) { var tmpDir = tmp.dirSync(); @@ -35,7 +49,8 @@ describe('webpack-subresource-integrity', function describe() { }, output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ new CommonsChunkPlugin({ name: 'chunk1', chunks: ['chunk2'] }), @@ -79,7 +94,8 @@ describe('webpack-subresource-integrity', function describe() { output: { path: tmpDir.name, filename: 'bundle.js', - chunkFilename: 'chunk.js' + chunkFilename: 'chunk.js', + crossOriginLoading: 'anonymous' }, plugins: [ new SriPlugin({ hashFuncNames: ['sha256', 'sha384'] }) @@ -138,7 +154,8 @@ describe('webpack-subresource-integrity', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ plugins[pluginOrder[0]], @@ -168,7 +185,8 @@ describe('webpack-subresource-integrity', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ new webpack.HotModuleReplacementPlugin(), @@ -188,6 +206,7 @@ describe('webpack-subresource-integrity', function describe() { var tmpDir = tmp.dirSync(); function cleanup(err) { fs.unlinkSync(path.join(tmpDir.name, 'bundle.js')); + fs.unlinkSync(path.join(tmpDir.name, 'styles.css')); tmpDir.removeCallback(); callback(err); } @@ -197,12 +216,55 @@ describe('webpack-subresource-integrity', function describe() { path: tmpDir.name, filename: 'bundle.js' }, + module: { + loaders: [ + { test: /\.css$/, loader: createExtractTextLoader() } + ] + }, plugins: [ + new ExtractTextPlugin('styles.css'), new SriPlugin({ hashFuncNames: ['sha256'], enabled: false }) ] }; webpack(webpackConfig, function webpackCallback(err, result) { expect(typeof result.compilation.assets['bundle.js'].integrity).toBe('undefined'); + expect(result.compilation.warnings).toEqual([]); + expect(result.compilation.errors).toEqual([]); + cleanup(err); + }); + }); + + it('should error when code splitting is used with crossOriginLoading', function it(callback) { + var tmpDir = tmp.dirSync(); + function cleanup(err) { + fs.unlinkSync(path.join(tmpDir.name, 'bundle.js')); + try { + fs.unlinkSync(path.join(tmpDir.name, '0.bundle.js')); + } catch (e) { + fs.unlinkSync(path.join(tmpDir.name, '1.bundle.js')); + } + tmpDir.removeCallback(); + callback(err); + } + var webpackConfig = { + entry: path.join(__dirname, './chunk1.js'), + output: { + path: tmpDir.name, + filename: 'bundle.js' + }, + plugins: [ + new SriPlugin({ hashFuncNames: ['sha256', 'sha384'] }) + ] + }; + webpack(webpackConfig, function webpackCallback(err, result) { + expect(result.compilation.warnings.length).toEqual(1); + expect(result.compilation.warnings[0]).toBeAn(Error); + expect(result.compilation.warnings[0].message).toMatch( + /Set webpack option output.crossOriginLoading when using this plugin/); + expect(result.compilation.errors.length).toEqual(1); + expect(result.compilation.errors[0]).toBeAn(Error); + expect(result.compilation.errors[0].message).toMatch( + /webpack option output.crossOriginLoading not set, code splitting will not work!/); cleanup(err); }); }); @@ -213,7 +275,7 @@ describe('plugin options', function describe() { var plugin = new SriPlugin('sha256'); expect(plugin.options.hashFuncNames).toEqual(['sha256']); expect(plugin.options.deprecatedOptions).toBeTruthy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); expect(dummyCompilation.warnings.length).toBe(1); @@ -225,7 +287,7 @@ describe('plugin options', function describe() { var plugin = new SriPlugin(['sha256', 'sha384']); expect(plugin.options.hashFuncNames).toEqual(['sha256', 'sha384']); expect(plugin.options.deprecatedOptions).toBeTruthy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); expect(dummyCompilation.warnings.length).toBe(1); @@ -239,7 +301,7 @@ describe('plugin options', function describe() { }); expect(plugin.options.hashFuncNames).toEqual(['sha256', 'sha384']); expect(plugin.options.deprecatedOptions).toBeFalsy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); expect(dummyCompilation.warnings.length).toBe(0); @@ -249,7 +311,7 @@ describe('plugin options', function describe() { var plugin = new SriPlugin({ hashFuncNames: 'sha256' }); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(1); expect(dummyCompilation.warnings.length).toBe(0); @@ -262,7 +324,7 @@ describe('plugin options', function describe() { var plugin = new SriPlugin({ hashFuncNames: [1234] }); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(1); expect(dummyCompilation.warnings.length).toBe(0); @@ -275,7 +337,7 @@ describe('plugin options', function describe() { var plugin = new SriPlugin({ hashFuncNames: ['frobnicate'] }); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(1); expect(dummyCompilation.warnings.length).toBe(0); @@ -291,12 +353,14 @@ describe('plugin options', function describe() { }); expect(plugin.options.hashFuncNames).toEqual(['sha256']); expect(plugin.options.deprecatedOptions).toBeFalsy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(1); - expect(dummyCompilation.warnings.length).toBe(0); + expect(dummyCompilation.warnings.length).toBe(1); expect(dummyCompilation.errors[0].message).toMatch( /options.crossorigin must be a string./); + expect(dummyCompilation.warnings[0].message).toMatch( + /set webpack option output.crossOriginLoading/); }); it('warns if the crossorigin attribute is not recognized', function it() { @@ -307,34 +371,40 @@ describe('plugin options', function describe() { expect(plugin.options.hashFuncNames).toEqual(['sha256']); expect(plugin.options.crossorigin).toBe('foo'); expect(plugin.options.deprecatedOptions).toBeFalsy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); - expect(dummyCompilation.warnings.length).toBe(1); + expect(dummyCompilation.warnings.length).toBe(2); expect(dummyCompilation.warnings[0].message).toMatch( + /set webpack option output.crossOriginLoading/); + expect(dummyCompilation.warnings[1].message).toMatch( /specified a value for the crossorigin option that is not part of the set of standard values/); }); - it('accepts anonymous crossorigin without warning', function it() { + it('accepts anonymous crossorigin without warning about standard values', function it() { var plugin = new SriPlugin({ hashFuncNames: ['sha256'], crossorigin: 'anonymous' }); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); - expect(dummyCompilation.warnings.length).toBe(0); + expect(dummyCompilation.warnings.length).toBe(1); + expect(dummyCompilation.warnings[0].message).toMatch( + /set webpack option output.crossOriginLoading/); }); - it('accepts use-credentials crossorigin without warning', function it() { + it('accepts use-credentials crossorigin without warning about standard values', function it() { var plugin = new SriPlugin({ hashFuncNames: ['sha256'], crossorigin: 'use-credentials' }); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); expect(dummyCompilation.errors.length).toBe(0); - expect(dummyCompilation.warnings.length).toBe(0); + expect(dummyCompilation.warnings.length).toBe(1); + expect(dummyCompilation.warnings[0].message).toMatch( + /set webpack option output.crossOriginLoading/); }); it('uses default options', function it() { @@ -342,14 +412,34 @@ describe('plugin options', function describe() { hashFuncNames: ['sha256'] }); expect(plugin.options.hashFuncNames).toEqual(['sha256']); - expect(plugin.options.crossorigin).toBe('anonymous'); expect(plugin.options.enabled).toBeTruthy(); expect(plugin.options.deprecatedOptions).toBeFalsy(); - var dummyCompilation = { warnings: [], errors: [] }; + var dummyCompilation = testCompilation(); plugin.validateOptions(dummyCompilation); + expect(plugin.options.crossorigin).toBe('anonymous'); expect(dummyCompilation.errors.length).toBe(0); expect(dummyCompilation.warnings.length).toBe(0); }); + + it('should warn when output.crossOriginLoading is not set', function it() { + var plugin = new SriPlugin({ hashFuncNames: ['sha256'] }); + var dummyCompilation = { + warnings: [], + errors: [], + compiler: { + options: { + output: { + crossOriginLoading: false + } + } + } + }; + plugin.validateOptions(dummyCompilation); + expect(dummyCompilation.errors.length).toBe(0); + expect(dummyCompilation.warnings.length).toBe(1); + expect(dummyCompilation.warnings[0].message).toMatch( + /Set webpack option output.crossOriginLoading when using this plugin/); + }); }); describe('html-webpack-plugin', function describe() { @@ -365,7 +455,8 @@ describe('html-webpack-plugin', function describe() { entry: path.join(__dirname, './a.js'), output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ new HtmlWebpackPlugin({ favicon: 'test/test.png' }), @@ -395,7 +486,8 @@ describe('html-webpack-plugin', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, module: { loaders: [ @@ -453,7 +545,8 @@ describe('html-webpack-plugin', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'bundle.js' + filename: 'bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ new HtmlWebpackPlugin(), @@ -497,7 +590,8 @@ describe('html-webpack-plugin', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'subdir/bundle.js' + filename: 'subdir/bundle.js', + crossOriginLoading: 'anonymous' }, plugins: [ new HtmlWebpackPlugin({ @@ -535,6 +629,41 @@ describe('html-webpack-plugin', function describe() { }); }); + it('should warn when calling htmlWebpackPlugin.options.sriCrossOrigin', function it(callback) { + var tmpDir = tmp.dirSync(); + var indexEjs = path.join(tmpDir.name, 'index.ejs'); + function cleanup(err) { + fs.unlinkSync(indexEjs); + fs.unlinkSync(path.join(tmpDir.name, 'bundle.js')); + fs.unlinkSync(path.join(tmpDir.name, 'index.html')); + tmpDir.removeCallback(); + callback(err); + } + fs.writeFileSync(indexEjs, '<% htmlWebpackPlugin.options.sriCrossOrigin %>'); + var webpackConfig = { + entry: path.join(__dirname, './dummy.js'), + output: { + filename: 'bundle.js', + path: tmpDir.name, + crossOriginLoading: 'anonymous' + }, + plugins: [ + new HtmlWebpackPlugin({ + template: indexEjs + }), + new SriPlugin({ hashFuncNames: ['sha256', 'sha384'] }) + ] + }; + webpack(webpackConfig, function webpackCallback(err, result) { + expect(result.compilation.warnings.length).toEqual(1); + expect(result.compilation.warnings[0]).toBeAn(Error); + expect(result.compilation.warnings[0].message).toEqual( + 'webpack-subresource-integrity: htmlWebpackPlugin.options.sriCrossOrigin is deprecated, use webpackConfig.output.crossOriginLoading instead.' + ); + cleanup(err); + }); + }); + it('should work with subdirectories and a custom template', function it(callback) { var tmpDir = tmp.dirSync(); function cleanup() { @@ -548,7 +677,8 @@ describe('html-webpack-plugin', function describe() { entry: path.join(__dirname, './dummy.js'), output: { path: tmpDir.name, - filename: 'subdir/bundle.js' + filename: 'subdir/bundle.js', + crossOriginLoading: 'anonymous' }, module: { loaders: [ @@ -613,7 +743,8 @@ describe('html-webpack-plugin', function describe() { output: { path: tmpDir.name, filename: 'bundle.js', - publicPath: '/' + publicPath: '/', + crossOriginLoading: 'anonymous' }, plugins: [ new HtmlWebpackPlugin(), @@ -665,7 +796,8 @@ describe('html-webpack-plugin', function describe() { output: { path: subDir, filename: 'bundle.js', - publicPath: '/' + publicPath: '/', + crossOriginLoading: 'anonymous' }, plugins: [ new HtmlWebpackPlugin({