From 0a9b897ce1085e759a63c2888689545ca5b248ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Sat, 9 Nov 2024 16:46:17 +0100 Subject: [PATCH 1/9] Adding CROSSORIGIN handling --- .circleci/config.yml | 2 +- CHANGELOG.md | 8 +++ README.md | 4 +- tests/app/tests/test_webpack.py | 13 +++- webpack_loader/config.py | 4 ++ webpack_loader/loaders.py | 71 +++++++++++++++---- webpack_loader/templatetags/webpack_loader.py | 10 +-- webpack_loader/utils.py | 14 ++-- 8 files changed, 100 insertions(+), 26 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 38428843..3a3d1488 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ workflows: matrix: parameters: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - django-version: ["3.2", "4.2", "5.0", "5.1"] + django-version: ["4.2", "5.0"] exclude: - python-version: "3.8" django-version: "5.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca50111..6f2115a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,18 @@ For more general information, view the [readme](README.md). Releases are added to the [github release page](https://github.com/ezhome/django-webpack-loader/releases). +## --- INSERT VERSION HERE --- + +- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary (and enabled) + ## [3.1.1] -- 2024-08-30 - Add support for Django 5.1 +## [3.2.0] -- 2024-07-28 + +- Remove support for Django 3.x (LTS is EOL) + ## [3.1.0] -- 2024-04-04 Support `webpack_asset` template tag to render transformed assets URL: `{% webpack_asset 'path/to/original/file' %} == "/static/assets/resource-3c9e4020d3e3c7a09c68.txt"` diff --git a/README.md b/README.md index 3f9bf559..2cf5b46d 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,9 @@ WEBPACK_LOADER = { - `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(( - ''), + ''), result.rendered_content ) diff --git a/webpack_loader/config.py b/webpack_loader/config.py index 7045224d..6df4e087 100644 --- a/webpack_loader/config.py +++ b/webpack_loader/config.py @@ -16,6 +16,10 @@ 'IGNORE': [r'.+\.hot-update.js', r'.+\.map'], 'LOADER_CLASS': 'webpack_loader.loaders.WebpackLoader', 'INTEGRITY': False, + # See https://shubhamjain.co/2018/09/08/subresource-integrity-crossorigin/ + # See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin + # type is Literal['anonymous', 'use-credentials', ''] + 'CROSSORIGIN': '', # Whenever the global setting for SKIP_COMMON_CHUNKS is changed, please # update the fallback value in get_skip_common_chunks (utils.py). 'SKIP_COMMON_CHUNKS': False, diff --git a/webpack_loader/loaders.py b/webpack_loader/loaders.py index 9e6c52aa..64859753 100644 --- a/webpack_loader/loaders.py +++ b/webpack_loader/loaders.py @@ -1,10 +1,15 @@ import json -import time import os +import time +from functools import lru_cache from io import open +from typing import Dict, Optional +from urllib.parse import urlparse +from warnings import warn from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage +from django.http import HttpRequest from .exceptions import ( WebpackError, @@ -13,6 +18,21 @@ WebpackBundleLookupError, ) +_CROSSORIGIN_NO_REQUEST = ( + 'The crossorigin attribute might be necessary but you did not pass a ' + 'request object. django_webpack_loader needs a request object to be able ' + 'to know when to emit the crossorigin attribute on link and script tags.') +_CROSSORIGIN_NO_HOST = ( + 'You have passed the request object but it does not have a "HTTP_HOST", ' + 'thus django_webpack_loader can\'t know if the crossorigin header will ' + 'be necessary or not.') + + +@lru_cache(maxsize=100) +def _get_netloc(url: str) -> str: + 'Return a cached netloc (host:port) for the passed `url`.' + return urlparse(url=url).netloc + class WebpackLoader: _assets = {} @@ -42,19 +62,46 @@ def get_asset_by_source_filename(self, name): files = self.get_assets()["assets"].values() return next((x for x in files if x.get("sourceFilename") == name), None) - def get_integrity_attr(self, chunk): - if not self.config.get("INTEGRITY"): - return " " - - integrity = chunk.get("integrity") + def _add_crossorigin( + self, request: Optional[HttpRequest], chunk_url: str, + integrity: str, attrs: str) -> str: + 'Return an added `crossorigin` attribute if necessary.' + def_value = f' integrity="{integrity}" ' + cfgval: str = self.config.get('CROSSORIGIN') + if not request: + warn(message=_CROSSORIGIN_NO_REQUEST, category=RuntimeWarning) + return def_value + if 'crossorigin' in attrs.lower(): + return def_value + host: Optional[str] = request.META.get('HTTP_HOST') + if not host: + warn(message=_CROSSORIGIN_NO_HOST, category=RuntimeWarning) + return def_value + netloc = _get_netloc(url=chunk_url) + if netloc == '' or netloc == host: + # Crossorigin not necessary + return def_value + if cfgval == '': + return f'{def_value}crossorigin ' + return f'{def_value}crossorigin="{cfgval}" ' + + def get_integrity_attr( + self, chunk: Dict[str, str], request: Optional[HttpRequest], + attrs: str): + if not self.config.get('INTEGRITY'): + # Crossorigin only necessary when integrity is used + 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. Maybe you forgot to add ' - "integrity: true in your BundleTracker configuration?" - ) - - return ' integrity="{}" '.format(integrity.partition(" ")[0]) + 'The stats file does not contain valid data: INTEGRITY is set ' + 'to True, but chunk does not contain "integrity" key. Maybe ' + 'you forgot to add integrity: true in your ' + 'BundleTrackerPlugin configuration?') + return self._add_crossorigin( + request=request, chunk_url=chunk['url'], integrity=integrity, + attrs=attrs) def filter_chunks(self, chunks): filtered_chunks = [] diff --git a/webpack_loader/templatetags/webpack_loader.py b/webpack_loader/templatetags/webpack_loader.py index e70c6dd7..6b02e55a 100644 --- a/webpack_loader/templatetags/webpack_loader.py +++ b/webpack_loader/templatetags/webpack_loader.py @@ -20,11 +20,11 @@ def render_bundle( if skip_common_chunks is None: skip_common_chunks = utils.get_skip_common_chunks(config) + request = context.get('request') url_to_tag_dict = utils.get_as_url_to_tag_dict( - bundle_name, extension=extension, config=config, suffix=suffix, - attrs=attrs, is_preload=is_preload) + bundle_name, request=request, extension=extension, config=config, + suffix=suffix, attrs=attrs, is_preload=is_preload) - request = context.get('request') if request is None: if skip_common_chunks: warn(message=_WARNING_MESSAGE, category=RuntimeWarning) @@ -35,7 +35,7 @@ def render_bundle( used_urls = request._webpack_loader_used_urls = set() if skip_common_chunks: url_to_tag_dict = {url: tag for url, tag in url_to_tag_dict.items() if url not in used_urls} - used_urls.update(url_to_tag_dict.keys()) + used_urls.update(url_to_tag_dict) return mark_safe('\n'.join(url_to_tag_dict.values())) @@ -43,10 +43,12 @@ def render_bundle( def webpack_static(asset_name, config='DEFAULT'): return utils.get_static(asset_name, config=config) + @register.simple_tag def webpack_asset(asset_name, config='DEFAULT'): return utils.get_asset(asset_name, config=config) + @register.simple_tag(takes_context=True) def get_files( context, bundle_name, extension=None, config='DEFAULT', diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index 25423c02..99a3d574 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -57,7 +57,9 @@ def get_files(bundle_name, extension=None, config='DEFAULT'): return list(_get_bundle(loader, bundle_name, extension)) -def get_as_url_to_tag_dict(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False): +def get_as_url_to_tag_dict( + bundle_name, request=None, extension=None, config='DEFAULT', suffix='', + attrs='', is_preload=False): ''' Get a dict of URLs to formatted ' + '' ).format( ''.join([chunk['url'], suffix]), attrs, - loader.get_integrity_attr(chunk, request, attrs), + loader.get_integrity_attr(chunk, request, attrs_l), + loader.get_nonce_attr(chunk, request, attrs_l), ) elif chunk['name'].endswith(('.css', '.css.gz')): result[chunk['url']] = ( - '' + '' ).format( ''.join([chunk['url'], suffix]), attrs, '"stylesheet"' if not is_preload else '"preload" as="style"', - loader.get_integrity_attr(chunk, request, attrs), + loader.get_integrity_attr(chunk, request, attrs_l), + loader.get_nonce_attr(chunk, request, attrs_l), ) return result From 5da319b285cd9a1ca6eaf760aa5097416decd529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Mon, 29 Jul 2024 01:47:25 +0200 Subject: [PATCH 3/9] Wording fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627dd8a1..5d4a9fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Releases are added to the ## --- INSERT VERSION HERE --- -- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary (and enabled) +- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary - Use `request.csp_nonce` from [django-csp](https://github.com/mozilla/django-csp) if available and configured ## [3.1.1] -- 2024-08-30 From 31e379ed1b3ccb59e205581ad7969cfa9def1688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Mon, 29 Jul 2024 02:19:06 +0200 Subject: [PATCH 4/9] Bump version --- webpack_loader/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack_loader/__init__.py b/webpack_loader/__init__.py index 7ad82065..30031b94 100644 --- a/webpack_loader/__init__.py +++ b/webpack_loader/__init__.py @@ -1,5 +1,5 @@ __author__ = "Vinta Software" -__version__ = "3.1.1" +__version__ = "3.2.0" import django From 2048476b958159087f75a5a7708a6ecb2eac5c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Mon, 29 Jul 2024 02:56:37 +0200 Subject: [PATCH 5/9] Better living through py3.8 compatible typing --- webpack_loader/loaders.py | 12 ++++++------ webpack_loader/templatetags/webpack_loader.py | 19 +++++++++++-------- webpack_loader/utils.py | 19 +++++++++++++------ 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/webpack_loader/loaders.py b/webpack_loader/loaders.py index 9319d1d4..c3c0a05f 100644 --- a/webpack_loader/loaders.py +++ b/webpack_loader/loaders.py @@ -1,7 +1,7 @@ import json import os import time -from functools import cached_property, lru_cache +from functools import lru_cache from io import open from typing import Dict, Optional from urllib.parse import urlparse @@ -9,7 +9,7 @@ from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage -from django.http import HttpRequest +from django.http.request import HttpRequest from .exceptions import ( WebpackError, @@ -22,19 +22,19 @@ 'The crossorigin attribute might be necessary but you did not pass a ' 'request object. django_webpack_loader needs a request object to be able ' 'to know when to emit the crossorigin attribute on link and script tags. ' - 'Bundle name: {chunk_name}') + 'Chunk name: {chunk_name}') _CROSSORIGIN_NO_HOST = ( 'You have passed the request object but it does not have a "HTTP_HOST", ' 'thus django_webpack_loader can\'t know if the crossorigin header will ' - 'be necessary or not. Bundle name: {chunk_name}') + 'be necessary or not. Chunk name: {chunk_name}') _NONCE_NO_REQUEST = ( 'You have enabled the adding of nonce attributes to generated tags via ' 'django_webpack_loader, but haven\'t passed a request. ' - 'Bundle name: {chunk_name}') + 'Chunk name: {chunk_name}') _NONCE_NO_CSPNONCE = ( 'django_webpack_loader can\'t generate a nonce tag for a bundle, ' 'because the passed request doesn\'t contain a "csp_nonce". ' - 'Bundle name: {chunk_name}') + 'Chunk name: {chunk_name}') @lru_cache(maxsize=100) diff --git a/webpack_loader/templatetags/webpack_loader.py b/webpack_loader/templatetags/webpack_loader.py index 6b02e55a..5b838a7a 100644 --- a/webpack_loader/templatetags/webpack_loader.py +++ b/webpack_loader/templatetags/webpack_loader.py @@ -1,5 +1,7 @@ +from typing import Optional from warnings import warn +from django.http.request import HttpRequest from django.template import Library from django.utils.safestring import mark_safe @@ -20,23 +22,24 @@ def render_bundle( if skip_common_chunks is None: skip_common_chunks = utils.get_skip_common_chunks(config) - request = context.get('request') - url_to_tag_dict = utils.get_as_url_to_tag_dict( + request: Optional[HttpRequest] = context.get('request') + tags = utils.get_as_url_to_tag_dict( bundle_name, request=request, extension=extension, config=config, suffix=suffix, attrs=attrs, is_preload=is_preload) if request is None: if skip_common_chunks: warn(message=_WARNING_MESSAGE, category=RuntimeWarning) - return mark_safe('\n'.join(url_to_tag_dict.values())) + return mark_safe('\n'.join(tags.values())) used_urls = getattr(request, '_webpack_loader_used_urls', None) - if not used_urls: - used_urls = request._webpack_loader_used_urls = set() + if used_urls is None: + used_urls = set() + setattr(request, '_webpack_loader_used_urls', used_urls) if skip_common_chunks: - url_to_tag_dict = {url: tag for url, tag in url_to_tag_dict.items() if url not in used_urls} - used_urls.update(url_to_tag_dict) - return mark_safe('\n'.join(url_to_tag_dict.values())) + tags = {url: tag for url, tag in tags.items() if url not in used_urls} + used_urls.update(tags) + return mark_safe('\n'.join(tags.values())) @register.simple_tag diff --git a/webpack_loader/utils.py b/webpack_loader/utils.py index dc0279b5..e789f70b 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -1,8 +1,12 @@ -from collections import OrderedDict from functools import lru_cache from importlib import import_module +from typing import Optional, OrderedDict + from django.conf import settings +from django.http.request import HttpRequest + from .config import load_config +from .loaders import WebpackLoader def import_string(dotted_path): @@ -21,7 +25,7 @@ def import_string(dotted_path): @lru_cache(maxsize=None) -def get_loader(config_name): +def get_loader(config_name) -> WebpackLoader: config = load_config(config_name) loader_class = import_string(config['LOADER_CLASS']) return loader_class(config_name, config) @@ -56,8 +60,9 @@ def get_files(bundle_name, extension=None, config='DEFAULT'): def get_as_url_to_tag_dict( - bundle_name, request=None, extension=None, config='DEFAULT', suffix='', - attrs='', is_preload=False): + bundle_name, request: Optional[HttpRequest] = None, extension=None, + config='DEFAULT', suffix='', attrs='', is_preload=False +) -> OrderedDict[str, str]: ''' Get a dict of URLs to formatted ' + ), result.rendered_content) + self.assertIn(( + ''), + result.rendered_content + ) + + def test_integrity_with_crosorigin_anonymous(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'anonymous'}): + view = TemplateView.as_view(template_name='single.html') + request = self.factory.get('/') + request.META['HTTP_HOST'] = 'crossorigen-custom-static-host.com' + result = view(request) + + self.assertIn(( + '' + ), result.rendered_content) + self.assertIn(( + ''), + result.rendered_content + ) + + def test_integrity_with_crosorigin_use_credentials(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'use-credentials'}): + view = TemplateView.as_view(template_name='single.html') + request = self.factory.get('/') + request.META['HTTP_HOST'] = 'crossorigen-custom-static-host.com' + result = view(request) + + self.assertIn(( + '' + ), result.rendered_content) + self.assertIn(( + ''), + result.rendered_content + ) + def test_integrity_missing_config(self): self.compile_bundles('webpack.config.integrity.js') @@ -857,3 +946,90 @@ def test_get_as_tags_direct_usage(self): self.assertEqual(tags[0], asset_vendor) self.assertEqual(tags[1], asset_app1) self.assertEqual(tags[2], asset_app2) + + def test_get_url_to_tag_dict_with_nonce(self): + """Test the get_as_url_to_tag_dict function with nonce attribute handling.""" + # Setup FakeWebpackLoader with CSP_NONCE enabled + + with self.settings( + WEBPACK_LOADER={ + "DEFAULT": { + "CSP_NONCE": True, + }, + } + ): + from webpack_loader.utils import get_as_url_to_tag_dict, get_loader + + self.compile_bundles('webpack.config.simple.js') + + # Use default config but enable CSP_NONCE + loader = get_loader(DEFAULT_CONFIG) + original_config = loader.config.copy() + try: + # Test with CSP_NONCE enabled + loader.config['CSP_NONCE'] = True + + # Create a request with csp_nonce + request = self.factory.get('/') + request.csp_nonce = "test-nonce-123" + + # Get tag dict with nonce enabled + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) + + # Verify nonce is in the tag + self.assertIn('nonce="test-nonce-123"', tag_dict['/static/webpack_bundles/main.js']) + + # Test with existing nonce in attrs - should not duplicate + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='nonce="existing-nonce"', request=request) + self.assertIn('nonce="existing-nonce"', tag_dict['/static/webpack_bundles/main.js']) + self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/webpack_bundles/main.js']) + + # Test without request - should not have nonce and should emit warning + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=None) + self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) + + # Test with request but no csp_nonce attribute - should not have nonce and should emit warning + request_without_nonce = self.factory.get('/') + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_without_nonce) + self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) + + # Test with CSP_NONCE disabled - should not have nonce + loader.config['CSP_NONCE'] = False + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) + self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) + + finally: + # Restore original config + loader.config = original_config + + def test_get_url_to_tag_dict_with_different_extensions(self): + """Test the get_as_url_to_tag_dict function with different file extensions.""" + + + with self.settings( + WEBPACK_LOADER={ + "DEFAULT": { + "CSP_NONCE": True, + }, + } + ): + from webpack_loader.utils import get_as_url_to_tag_dict + self.compile_bundles('webpack.config.simple.js') + + # Create a request with csp_nonce + request = self.factory.get('/') + request.csp_nonce = "test-nonce-123" + + # Test with different extensions + + # JavaScript file + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) + self.assertIn(''), result.rendered_content) self.assertIn(( - '' + ''), result.rendered_content ) self.assertIn(( - ''), + ''), result.rendered_content ) @@ -946,90 +946,76 @@ def test_get_as_tags_direct_usage(self): self.assertEqual(tags[0], asset_vendor) self.assertEqual(tags[1], asset_app1) self.assertEqual(tags[2], asset_app2) - + def test_get_url_to_tag_dict_with_nonce(self): """Test the get_as_url_to_tag_dict function with nonce attribute handling.""" - # Setup FakeWebpackLoader with CSP_NONCE enabled - with self.settings( - WEBPACK_LOADER={ - "DEFAULT": { - "CSP_NONCE": True, - }, - } - ): - from webpack_loader.utils import get_as_url_to_tag_dict, get_loader + self.compile_bundles('webpack.config.simple.js') - self.compile_bundles('webpack.config.simple.js') + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}): + # Create a request with csp_nonce + request = self.factory.get('/') + request.csp_nonce = "test-nonce-123" + + # Get tag dict with nonce enabled + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) + # Verify nonce is in the tag + self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.js']) + + # Test with existing nonce in attrs - should not duplicate + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='nonce="existing-nonce"', request=request) + self.assertIn('nonce="existing-nonce"', tag_dict['/static/django_webpack_loader_bundles/main.js']) + self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.js']) + + # Test without request - should not have nonce + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=None) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js']) + + # Test with request but no csp_nonce attribute - should not have nonce + request_without_nonce = self.factory.get('/') + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_without_nonce) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js']) + + def test_get_url_to_tag_dict_with_nonce_disabled(self): + self.compile_bundles('webpack.config.simple.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {"CSP_NONCE": False, 'CACHE': False}): + # Create a request without csp_nonce + request = self.factory.get('/') + + # should not have nonce + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js']) + + # Create a request with csp_nonce + request_with_nonce = self.factory.get('/') + request_with_nonce.csp_nonce = "test-nonce-123" + + # Test with CSP_NONCE disabled - should not have nonce + tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_with_nonce) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/main.js']) - # Use default config but enable CSP_NONCE - loader = get_loader(DEFAULT_CONFIG) - original_config = loader.config.copy() - try: - # Test with CSP_NONCE enabled - loader.config['CSP_NONCE'] = True - - # Create a request with csp_nonce - request = self.factory.get('/') - request.csp_nonce = "test-nonce-123" - - # Get tag dict with nonce enabled - tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) - - # Verify nonce is in the tag - self.assertIn('nonce="test-nonce-123"', tag_dict['/static/webpack_bundles/main.js']) - - # Test with existing nonce in attrs - should not duplicate - tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='nonce="existing-nonce"', request=request) - self.assertIn('nonce="existing-nonce"', tag_dict['/static/webpack_bundles/main.js']) - self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/webpack_bundles/main.js']) - - # Test without request - should not have nonce and should emit warning - tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=None) - self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) - - # Test with request but no csp_nonce attribute - should not have nonce and should emit warning - request_without_nonce = self.factory.get('/') - tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request_without_nonce) - self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) - - # Test with CSP_NONCE disabled - should not have nonce - loader.config['CSP_NONCE'] = False - tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) - self.assertNotIn('nonce=', tag_dict['/static/webpack_bundles/main.js']) - - finally: - # Restore original config - loader.config = original_config - def test_get_url_to_tag_dict_with_different_extensions(self): """Test the get_as_url_to_tag_dict function with different file extensions.""" + self.compile_bundles('webpack.config.simple.js') - with self.settings( - WEBPACK_LOADER={ - "DEFAULT": { - "CSP_NONCE": True, - }, - } - ): - from webpack_loader.utils import get_as_url_to_tag_dict - self.compile_bundles('webpack.config.simple.js') - + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}): # Create a request with csp_nonce request = self.factory.get('/') request.csp_nonce = "test-nonce-123" - - # Test with different extensions - + # JavaScript file tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request) - self.assertIn(''), result.rendered_content) + + def test_integrity_with_crossorigin_empty(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': '', 'CACHE': False}): + view = TemplateView.as_view(template_name='home.html') + request = self.factory.get('/') + request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com' + result = view(request) + + self.assertIn(( + '' + ), result.rendered_content) + + def test_integrity_with_crossorigin_anonymous(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'anonymous', 'CACHE': False}): + view = TemplateView.as_view(template_name='home.html') + request = self.factory.get('/') + request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com' + result = view(request) + + self.assertIn(( + '' + ), result.rendered_content) + + def test_integrity_with_crossorigin_use_credentials(self): + self.compile_bundles('webpack.config.integrity.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {'INTEGRITY': True, 'CROSSORIGIN': 'use-credentials', 'CACHE': False}): + view = TemplateView.as_view(template_name='home.html') + request = self.factory.get('/') + request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com' + result = view(request) + + self.assertIn(( + '' + ), result.rendered_content) + + def test_get_url_to_tag_dict_with_nonce(self): + """Test the get_as_url_to_tag_dict function with nonce attribute handling.""" + + self.compile_bundles('webpack.config.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {"CSP_NONCE": True, 'CACHE': False}): + # Create a request with csp_nonce + request = self.factory.get('/') + request.csp_nonce = "test-nonce-123" + + # Get tag dict with nonce enabled + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request) + # Verify nonce is in the tag + self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + + # Test with existing nonce in attrs - should not duplicate + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='nonce="existing-nonce"', request=request) + self.assertIn('nonce="existing-nonce"', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + self.assertNotIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + + # Test without request - should not have nonce + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=None) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + + # Test with request but no csp_nonce attribute - should not have nonce + request_without_nonce = self.factory.get('/') + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request_without_nonce) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + + def test_get_url_to_tag_dict_with_nonce_disabled(self): + self.compile_bundles('webpack.config.js') + + loader = get_loader(DEFAULT_CONFIG) + with patch.dict(loader.config, {"CSP_NONCE": False, 'CACHE': False}): + # Create a request without csp_nonce + request = self.factory.get('/') + + # should not have nonce + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js']) + + # Create a request with csp_nonce + request_with_nonce = self.factory.get('/') + request_with_nonce.csp_nonce = "test-nonce-123" + + # Test with CSP_NONCE disabled - should not have nonce + tag_dict = get_as_url_to_tag_dict('resources', extension='js', attrs='', request=request_with_nonce) + self.assertNotIn('nonce=', tag_dict['/static/django_webpack_loader_bundles/resources.js']) diff --git a/tests_webpack5/webpack.config.integrity.js b/tests_webpack5/webpack.config.integrity.js new file mode 100644 index 00000000..c4af8761 --- /dev/null +++ b/tests_webpack5/webpack.config.integrity.js @@ -0,0 +1,27 @@ +var path = require("path"); +var BundleTracker = require('webpack-bundle-tracker'); + +module.exports = { + entry: { + resources: './assets/js/resources' + }, + + output: { + assetModuleFilename: 'assets/[name]-[contenthash][ext]', + path: path.resolve('./assets/django_webpack_loader_bundles/'), + publicPath: 'http://custom-static-host.com/', + }, + + module: { + rules: [{ test: /\.txt$/, type: 'asset/resource' }] + }, + + plugins: [ + new BundleTracker({path: __dirname, integrity: true}) + ], + + resolve: { + extensions: ['.js', '.jsx'] + }, +} + diff --git a/webpack_loader/config.py b/webpack_loader/config.py index 2291689b..e8263e75 100644 --- a/webpack_loader/config.py +++ b/webpack_loader/config.py @@ -28,16 +28,16 @@ } } +user_config = getattr(settings, 'WEBPACK_LOADER', DEFAULT_CONFIG) +user_config = dict( + (name, dict(DEFAULT_CONFIG['DEFAULT'], **cfg)) + for name, cfg in user_config.items() +) -def load_config(name): - user_config = getattr(settings, 'WEBPACK_LOADER', DEFAULT_CONFIG) +for entry in user_config.values(): + entry['ignores'] = [re.compile(I) for I in entry['IGNORE']] - user_config = dict( - (name, dict(DEFAULT_CONFIG['DEFAULT'], **cfg)) - for name, cfg in user_config.items() - ) - for entry in user_config.values(): - entry['ignores'] = [re.compile(I) for I in entry['IGNORE']] +def load_config(name): return user_config[name] diff --git a/webpack_loader/loaders.py b/webpack_loader/loaders.py index f02b34d8..2fde9310 100644 --- a/webpack_loader/loaders.py +++ b/webpack_loader/loaders.py @@ -109,14 +109,16 @@ def get_integrity_attr( 'The stats file does not contain valid data: INTEGRITY is set ' 'to True, but chunk does not contain "integrity" key. Maybe ' 'you forgot to add integrity: true in your ' - 'BundleTrackerPlugin configuration?') + 'BundleTrackerPlugin configuration?' + ) return self._add_crossorigin( - request=request, chunk=chunk, integrity=integrity, - attrs_l=attrs_l) + request=request, + chunk=chunk, + integrity=integrity, + attrs_l=attrs_l, + ) - def get_nonce_attr( - self, chunk: Dict[str, str], request: Optional[HttpRequest], - attrs: str) -> str: + def get_nonce_attr(self, chunk: Dict[str, str], request: Optional[HttpRequest], attrs: str) -> str: 'Return an added nonce for CSP when available.' if not self.config.get('CSP_NONCE'): return '' From 9a5a418ec57adc1c7ae804cba03b6a1d04b6b54b Mon Sep 17 00:00:00 2001 From: rvlb Date: Mon, 19 May 2025 17:22:39 -0300 Subject: [PATCH 8/9] Release 3.2.0 --- CHANGELOG.md | 7 ++----- examples/code-splitting/package.json | 2 +- examples/dynamic-imports/package.json | 2 +- examples/hot-reload/package.json | 2 +- examples/simple/package.json | 2 +- setup.cfg | 2 +- tests/package.json | 2 +- tests_webpack5/package.json | 2 +- 8 files changed, 9 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4a9fa5..29bdb807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,16 @@ For more general information, view the [readme](README.md). Releases are added to the [github release page](https://github.com/ezhome/django-webpack-loader/releases). -## --- INSERT VERSION HERE --- +## [3.2.0] -- 2025-05-19 - Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary - Use `request.csp_nonce` from [django-csp](https://github.com/mozilla/django-csp) if available and configured +- Remove support for Django 3.x (LTS is EOL) ## [3.1.1] -- 2024-08-30 - Add support for Django 5.1 -## [3.2.0] -- 2024-07-28 - -- Remove support for Django 3.x (LTS is EOL) - ## [3.1.0] -- 2024-04-04 Support `webpack_asset` template tag to render transformed assets URL: `{% webpack_asset 'path/to/original/file' %} == "/static/assets/resource-3c9e4020d3e3c7a09c68.txt"` diff --git a/examples/code-splitting/package.json b/examples/code-splitting/package.json index ef7d8f35..b13d5e8e 100644 --- a/examples/code-splitting/package.json +++ b/examples/code-splitting/package.json @@ -13,7 +13,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" } diff --git a/examples/dynamic-imports/package.json b/examples/dynamic-imports/package.json index 70c196e6..7c56c921 100644 --- a/examples/dynamic-imports/package.json +++ b/examples/dynamic-imports/package.json @@ -13,7 +13,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" }, diff --git a/examples/hot-reload/package.json b/examples/hot-reload/package.json index 7711a25d..d58c2061 100644 --- a/examples/hot-reload/package.json +++ b/examples/hot-reload/package.json @@ -15,7 +15,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" } diff --git a/examples/simple/package.json b/examples/simple/package.json index 7711a25d..d58c2061 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -15,7 +15,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" } diff --git a/setup.cfg b/setup.cfg index 5aef279b..ddb7da9e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [metadata] -description-file = README.rst +description_file = README.rst diff --git a/tests/package.json b/tests/package.json index a3d87979..7e4471a5 100644 --- a/tests/package.json +++ b/tests/package.json @@ -15,7 +15,7 @@ "react": "^16.0.0", "webpack": "^4.0.0", "compression-webpack-plugin": "^6.1.1", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^3.3.10", "webpack-dev-server": "^3.0.0" } diff --git a/tests_webpack5/package.json b/tests_webpack5/package.json index 70a0e517..7cf0d8f7 100644 --- a/tests_webpack5/package.json +++ b/tests_webpack5/package.json @@ -6,7 +6,7 @@ "license": "MIT", "devDependencies": { "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4" } } From 2142aba067618f9741b1dee731685c5801f106d6 Mon Sep 17 00:00:00 2001 From: rvlb Date: Mon, 19 May 2025 17:25:05 -0300 Subject: [PATCH 9/9] Update tests package-lock --- tests/package-lock.json | 15 ++++----------- tests_webpack5/package-lock.json | 15 ++++----------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/tests/package-lock.json b/tests/package-lock.json index 9c30fa1a..88fce45d 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -18,7 +18,7 @@ "mini-css-extract-plugin": "^0.9.0", "react": "^16.0.0", "webpack": "^4.0.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^3.3.10", "webpack-dev-server": "^3.0.0" } @@ -5713,12 +5713,6 @@ "integrity": "sha512-dvqe2I+cO5MzXCMhUnfYFa9MD+/760yx2aTAN1lqEcEkf896TxgrX373igVdqSJj6tQd0jnSLE1UMuKufqqxFw==", "dev": true }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, "node_modules/lodash.topairs": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.topairs/-/lodash.topairs-4.3.0.tgz", @@ -9214,16 +9208,15 @@ } }, "node_modules/webpack-bundle-tracker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.1.1.tgz", - "integrity": "sha512-4gBt5EPKZyjy48Djr+75KTfLH+ikqDklgQD+padwuTc9y+ULHl9B4zqnZkV6cTdH3R2KzQYUZDQzpGUu+k6b/A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.2.0.tgz", + "integrity": "sha512-/CV+Mre7jC+lSTjF7nYv36FpdwIQR15URKGHpLmdgGxxdYFQ858MV13J0qqR7Rn3wke3O41yX0tNntPv3uo1QA==", "dev": true, "dependencies": { "lodash.assign": "^4.2.0", "lodash.defaults": "^4.2.0", "lodash.foreach": "^4.5.0", "lodash.frompairs": "^4.0.1", - "lodash.get": "^4.4.2", "lodash.topairs": "^4.3.0" } }, diff --git a/tests_webpack5/package-lock.json b/tests_webpack5/package-lock.json index cdb44430..e380978e 100644 --- a/tests_webpack5/package-lock.json +++ b/tests_webpack5/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "webpack": "^5.92.0", - "webpack-bundle-tracker": "3.1.1", + "webpack-bundle-tracker": "3.2.0", "webpack-cli": "^5.1.4" } }, @@ -787,12 +787,6 @@ "integrity": "sha512-dvqe2I+cO5MzXCMhUnfYFa9MD+/760yx2aTAN1lqEcEkf896TxgrX373igVdqSJj6tQd0jnSLE1UMuKufqqxFw==", "dev": true }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, "node_modules/lodash.topairs": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.topairs/-/lodash.topairs-4.3.0.tgz", @@ -1276,16 +1270,15 @@ } }, "node_modules/webpack-bundle-tracker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.1.1.tgz", - "integrity": "sha512-4gBt5EPKZyjy48Djr+75KTfLH+ikqDklgQD+padwuTc9y+ULHl9B4zqnZkV6cTdH3R2KzQYUZDQzpGUu+k6b/A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-3.2.0.tgz", + "integrity": "sha512-/CV+Mre7jC+lSTjF7nYv36FpdwIQR15URKGHpLmdgGxxdYFQ858MV13J0qqR7Rn3wke3O41yX0tNntPv3uo1QA==", "dev": true, "dependencies": { "lodash.assign": "^4.2.0", "lodash.defaults": "^4.2.0", "lodash.foreach": "^4.5.0", "lodash.frompairs": "^4.0.1", - "lodash.get": "^4.4.2", "lodash.topairs": "^4.3.0" } },