From 238bba2599d4c572b831ee2bb111b96792297fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 16 Feb 2022 13:08:14 +0100 Subject: [PATCH 1/8] Added support for Subresource Integrity --- tests/app/templates/single.html | 14 +++++++++++ tests/app/tests/test_webpack.py | 19 ++++++++++++++ tests/webpack.config.integrity.js | 41 +++++++++++++++++++++++++++++++ webpack_loader/config.py | 1 + webpack_loader/loader.py | 20 ++++++++++++++- webpack_loader/utils.py | 20 ++++++++++++--- 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/app/templates/single.html create mode 100644 tests/webpack.config.integrity.js diff --git a/tests/app/templates/single.html b/tests/app/templates/single.html new file mode 100644 index 00000000..98e16f03 --- /dev/null +++ b/tests/app/templates/single.html @@ -0,0 +1,14 @@ +{% load render_bundle webpack_static from webpack_loader %} + + + + + Example + {% render_bundle 'main' 'css' %} + + + +
+ {% render_bundle 'main' 'js' %} + + diff --git a/tests/app/tests/test_webpack.py b/tests/app/tests/test_webpack.py index a4114a06..bd6f1d8a 100644 --- a/tests/app/tests/test_webpack.py +++ b/tests/app/tests/test_webpack.py @@ -210,6 +210,25 @@ def test_preload(self): ''), result.rendered_content) + def test_integrity(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True}): + view = TemplateView.as_view(template_name='single.html') + request = self.factory.get('/') + result = view(request) + + self.assertIn(( + ''), result.rendered_content) + self.assertIn(( + ''), + result.rendered_content + ) + def test_append_extensions(self): self.compile_bundles('webpack.config.gzipTest.js') view = TemplateView.as_view(template_name='append_extensions.html') diff --git a/tests/webpack.config.integrity.js b/tests/webpack.config.integrity.js new file mode 100644 index 00000000..d82de67b --- /dev/null +++ b/tests/webpack.config.integrity.js @@ -0,0 +1,41 @@ +var path = require("path"); +var webpack = require('webpack'); +var BundleTracker = require('webpack-bundle-tracker'); +var MiniCssExtractPlugin = require('mini-css-extract-plugin'); + + +module.exports = { + context: __dirname, + entry: './assets/js/index', + output: { + path: path.resolve('./assets/django_webpack_loader_bundles/'), + filename: "[name].js" + }, + + plugins: [ + new MiniCssExtractPlugin(), + new BundleTracker({path: __dirname, filename: './webpack-stats.json', integrity: true}), + ], + + module: { + rules: [ + // we pass the output from babel loader to react-hot loader + { + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-react'] + } + } + }, + { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], } + ], + }, + + resolve: { + modules: ['node_modules'], + extensions: ['.js', '.jsx'] + }, +} diff --git a/webpack_loader/config.py b/webpack_loader/config.py index c978d77f..8761b2f5 100644 --- a/webpack_loader/config.py +++ b/webpack_loader/config.py @@ -15,6 +15,7 @@ 'TIMEOUT': None, 'IGNORE': [r'.+\.hot-update.js', r'.+\.map'], 'LOADER_CLASS': 'webpack_loader.loader.WebpackLoader', + 'INTEGRITY': False, } } diff --git a/webpack_loader/loader.py b/webpack_loader/loader.py index c15d3122..f55067c6 100644 --- a/webpack_loader/loader.py +++ b/webpack_loader/loader.py @@ -38,6 +38,18 @@ def get_assets(self): return self._assets[self.name] return self.load_assets() + def get_integrity_attr(self, chunk): + if not self.config['INTEGRITY']: + return '' + + integrity = chunk.get('integrity') + if not integrity: + raise WebpackLoaderBadStatsError( + "The stats file does not contain valid data: INTEGRITY is set to True, " + "but chunk does not contain \"integrity\" key.") + + return 'integrity="{}"'.format(integrity.partition(' ')[0]) + def filter_chunks(self, chunks): filtered_chunks = [] @@ -53,9 +65,15 @@ def map_chunk_files_to_url(self, chunks): assets = self.get_assets() files = assets['assets'] + add_integrity = self.config['INTEGRITY'] + for chunk in chunks: url = self.get_chunk_url(files[chunk]) - yield { 'name': chunk, 'url': url } + + if add_integrity: + yield {'name': chunk, 'url': url, 'integrity': files[chunk].get('integrity')} + else: + yield {'name': chunk, 'url': url} def get_chunk_url(self, chunk_file): public_path = chunk_file.get('publicPath') diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index 008fef44..2086b3c8 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -60,6 +60,9 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= bundle = _get_bundle(bundle_name, extension, config) tags = [] + + loader = get_loader(config) + for chunk in bundle: if chunk['name'].endswith(('.js', '.js.gz')): if is_preload: @@ -68,12 +71,21 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= ).format(''.join([chunk['url'], suffix]), attrs)) else: tags.append(( - '' - ).format(''.join([chunk['url'], suffix]), attrs)) + '' + ).format( + ''.join([chunk['url'], suffix]), + attrs, + loader.get_integrity_attr(chunk), + )) elif chunk['name'].endswith(('.css', '.css.gz')): tags.append(( - '' - ).format(''.join([chunk['url'], suffix]), attrs, '"stylesheet"' if not is_preload else '"preload" as="style"')) + '' + ).format( + ''.join([chunk['url'], suffix]), + attrs, + '"stylesheet"' if not is_preload else '"preload" as="style"', + loader.get_integrity_attr(chunk), + )) return tags From 6578436b4384590031499ef16d0d8c7b968b6437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 16 Feb 2022 13:15:50 +0100 Subject: [PATCH 2/8] Readme update --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 45b62f9d..09115855 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ The `STATS_FILE` parameter represents the output file produced by `webpack-bundl - `TIMEOUT` is the number of seconds webpack_loader should wait for webpack to finish compiling before raising an exception. `0`, `None` or leaving the value out of settings disables timeouts +- `INTEGRITY` is flag enabling [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) on rendered `'), result.rendered_content) self.assertIn(( ''), + 'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30=" />'), result.rendered_content ) diff --git a/webpack_loader/loader.py b/webpack_loader/loader.py index f55067c6..3fb79b8c 100644 --- a/webpack_loader/loader.py +++ b/webpack_loader/loader.py @@ -48,7 +48,7 @@ def get_integrity_attr(self, chunk): "The stats file does not contain valid data: INTEGRITY is set to True, " "but chunk does not contain \"integrity\" key.") - return 'integrity="{}"'.format(integrity.partition(' ')[0]) + return ' integrity="{}" '.format(integrity.partition(' ')[0]) def filter_chunks(self, chunks): filtered_chunks = [] diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index 2086b3c8..294abf10 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -71,20 +71,20 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= ).format(''.join([chunk['url'], suffix]), attrs)) else: tags.append(( - '' + '' ).format( ''.join([chunk['url'], suffix]), attrs, - loader.get_integrity_attr(chunk), + loader.get_integrity_attr(chunk) or ' ', )) elif chunk['name'].endswith(('.css', '.css.gz')): tags.append(( - '' + '' ).format( ''.join([chunk['url'], suffix]), attrs, '"stylesheet"' if not is_preload else '"preload" as="style"', - loader.get_integrity_attr(chunk), + loader.get_integrity_attr(chunk) or ' ', )) return tags From 09ea1c708098e8a83899daa5cbc4439cdf968d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 16 Feb 2022 22:39:16 +0100 Subject: [PATCH 5/8] Do not rely on present INTEGRITY key in config --- webpack_loader/loader.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/webpack_loader/loader.py b/webpack_loader/loader.py index 3fb79b8c..b0902935 100644 --- a/webpack_loader/loader.py +++ b/webpack_loader/loader.py @@ -39,14 +39,15 @@ def get_assets(self): return self.load_assets() def get_integrity_attr(self, chunk): - if not self.config['INTEGRITY']: + if not self.config.get('INTEGRITY'): return '' integrity = chunk.get('integrity') if not integrity: raise WebpackLoaderBadStatsError( "The stats file does not contain valid data: INTEGRITY is set to True, " - "but chunk does not contain \"integrity\" key.") + "but chunk does not contain \"integrity\" key. Maybe you forgot to add " + "integrity: true in your BundleTracker configuration?") return ' integrity="{}" '.format(integrity.partition(' ')[0]) @@ -65,7 +66,7 @@ def map_chunk_files_to_url(self, chunks): assets = self.get_assets() files = assets['assets'] - add_integrity = self.config['INTEGRITY'] + add_integrity = self.config.get('INTEGRITY') for chunk in chunks: url = self.get_chunk_url(files[chunk]) From 9c36bbe7718c8963e620e7613dc4bbd002119e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 16 Feb 2022 22:43:50 +0100 Subject: [PATCH 6/8] Added testcase to check backward compatibility with missing INTEGRITY key --- tests/app/tests/test_webpack.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/app/tests/test_webpack.py b/tests/app/tests/test_webpack.py index 38f0d95f..e48b686a 100644 --- a/tests/app/tests/test_webpack.py +++ b/tests/app/tests/test_webpack.py @@ -229,6 +229,29 @@ def test_integrity(self): result.rendered_content ) + def test_integrity_missing_config(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + # remove INTEGRITY from config completely to test backward compatibility + integrity_from_config = loader.config.pop('INTEGRITY') + + view = TemplateView.as_view(template_name='single.html') + request = self.factory.get('/') + result = view(request) + + self.assertIn(( + ''), result.rendered_content + ) + self.assertIn(( + ''), + result.rendered_content + ) + + # return removed key + loader.config['INTEGRITY'] = integrity_from_config + def test_integrity_missing_hash(self): self.compile_bundles('webpack.config.simple.js') From dc2b5cba5ae76500fa46094991f53cd20193341e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 16 Feb 2022 22:46:50 +0100 Subject: [PATCH 7/8] Moved integrity attr whitespace separator to WebpackLoader --- webpack_loader/loader.py | 2 +- webpack_loader/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webpack_loader/loader.py b/webpack_loader/loader.py index b0902935..6d46384b 100644 --- a/webpack_loader/loader.py +++ b/webpack_loader/loader.py @@ -40,7 +40,7 @@ def get_assets(self): def get_integrity_attr(self, chunk): if not self.config.get('INTEGRITY'): - return '' + return ' ' integrity = chunk.get('integrity') if not integrity: diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index 294abf10..d3b49601 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -75,7 +75,7 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= ).format( ''.join([chunk['url'], suffix]), attrs, - loader.get_integrity_attr(chunk) or ' ', + loader.get_integrity_attr(chunk), )) elif chunk['name'].endswith(('.css', '.css.gz')): tags.append(( @@ -84,7 +84,7 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= ''.join([chunk['url'], suffix]), attrs, '"stylesheet"' if not is_preload else '"preload" as="style"', - loader.get_integrity_attr(chunk) or ' ', + loader.get_integrity_attr(chunk), )) return tags From 0a8ebdd5e6571676c6e6cbd57f5b2c7c43ad136e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Thu, 17 Feb 2022 13:42:57 -0300 Subject: [PATCH 8/8] Refactor _get_bundle to avoid repeated calls to get_loader --- webpack_loader/utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index d3b49601..b432dc49 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -35,8 +35,8 @@ def _filter_by_extension(bundle, extension): yield chunk -def _get_bundle(bundle_name, extension, config): - bundle = get_loader(config).get_bundle(bundle_name) +def _get_bundle(loader, bundle_name, extension): + bundle = loader.get_bundle(bundle_name) if extension: bundle = _filter_by_extension(bundle, extension) return bundle @@ -44,7 +44,8 @@ def _get_bundle(bundle_name, extension, config): def get_files(bundle_name, extension=None, config='DEFAULT'): '''Returns list of chunks from named bundle''' - return list(_get_bundle(bundle_name, extension, config)) + loader = get_loader(config) + return list(_get_bundle(loader, bundle_name, extension)) def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False): @@ -58,10 +59,9 @@ def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs= :return: a list of formatted tags as strings ''' - bundle = _get_bundle(bundle_name, extension, config) - tags = [] - loader = get_loader(config) + bundle = _get_bundle(loader, bundle_name, extension) + tags = [] for chunk in bundle: if chunk['name'].endswith(('.js', '.js.gz')):