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