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..5d4a9fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,19 @@ 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 +- Use `request.csp_nonce` from [django-csp](https://github.com/mozilla/django-csp) if available and configured + ## [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..7131c80e 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,11 @@ 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/__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 diff --git a/webpack_loader/config.py b/webpack_loader/config.py index 7045224d..e8263e75 100644 --- a/webpack_loader/config.py +++ b/webpack_loader/config.py @@ -16,9 +16,15 @@ '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, + # Use nonces from django-csp when available + 'CSP_NONCE': False } } diff --git a/webpack_loader/loaders.py b/webpack_loader/loaders.py index 9e6c52aa..c3c0a05f 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.request import HttpRequest from .exceptions import ( WebpackError, @@ -13,6 +18,30 @@ 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. ' + '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. 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. ' + '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". ' + 'Chunk name: {chunk_name}') + + +@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 +71,67 @@ 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: Dict[str, str], + integrity: str, attrs_l: str) -> str: + 'Return an added `crossorigin` attribute if necessary.' + def_value = f' integrity="{integrity}" ' + if not request: + message = _CROSSORIGIN_NO_REQUEST.format(chunk_name=chunk['name']) + warn(message=message, category=RuntimeWarning) + return def_value + if 'crossorigin' in attrs_l: + return def_value + host: Optional[str] = request.META.get('HTTP_HOST') + if not host: + message = _CROSSORIGIN_NO_HOST.format(chunk_name=chunk['name']) + warn(message=message, category=RuntimeWarning) + return def_value + netloc = _get_netloc(url=chunk['url']) + if netloc == '' or netloc == host: + # Crossorigin not necessary + return def_value + cfgval: str = self.config.get('CROSSORIGIN') + 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_l: str) -> 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=chunk, integrity=integrity, + attrs_l=attrs_l) + + 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 '' + if request is None: + message = _NONCE_NO_REQUEST.format(chunk_name=chunk['name']) + warn(message=message, category=RuntimeWarning) + return '' + nonce = getattr(request, 'csp_nonce', None) + if nonce is None: + message = _NONCE_NO_CSPNONCE.format(chunk_name=chunk['name']) + warn(message=message, category=RuntimeWarning) + return '' + if 'nonce=' in attrs.lower(): + return '' + return f'nonce="{nonce}" ' 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..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,33 +22,36 @@ def render_bundle( if skip_common_chunks is None: skip_common_chunks = utils.get_skip_common_chunks(config) - 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) + 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) - request = context.get('request') 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.keys()) - 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 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..e789f70b 100644 --- a/webpack_loader/utils.py +++ b/webpack_loader/utils.py @@ -1,9 +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 .config import load_config +from django.http.request import HttpRequest -_loaders = {} +from .config import load_config +from .loaders import WebpackLoader def import_string(dotted_path): @@ -21,12 +24,11 @@ def import_string(dotted_path): raise ImportError('%s doesn\'t look like a valid module path' % dotted_path) -def get_loader(config_name): - if config_name not in _loaders: - config = load_config(config_name) - loader_class = import_string(config['LOADER_CLASS']) - _loaders[config_name] = loader_class(config_name, config) - return _loaders[config_name] +@lru_cache(maxsize=None) +def get_loader(config_name) -> WebpackLoader: + config = load_config(config_name) + loader_class = import_string(config['LOADER_CLASS']) + return loader_class(config_name, config) def get_skip_common_chunks(config_name): @@ -57,7 +59,10 @@ 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: Optional[HttpRequest] = None, extension=None, + config='DEFAULT', suffix='', attrs='', is_preload=False +) -> OrderedDict[str, str]: ''' Get a dict of URLs to formatted ' + '' ).format( ''.join([chunk['url'], suffix]), attrs, - loader.get_integrity_attr(chunk), + 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), + loader.get_integrity_attr(chunk, request, attrs_l), + loader.get_nonce_attr(chunk, request, attrs_l), ) return result -def get_as_tags(bundle_name, extension=None, config='DEFAULT', suffix='', attrs='', is_preload=False): +def get_as_tags( + bundle_name, request=None, extension=None, config='DEFAULT', suffix='', + attrs='', is_preload=False): ''' Get a list of formatted