Skip to content

Commit 880e45e

Browse files
committed
More improvements & supporting django-csp
1 parent 2864cad commit 880e45e

File tree

5 files changed

+60
-24
lines changed

5 files changed

+60
-24
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Releases are added to the
88
## --- INSERT VERSION HERE ---
99

1010
- Automatically add `crossorigin` attributes to tags with `integrity` attributes when necessary (and enabled)
11+
- Use `request.csp_nonce` from [django-csp](https://github.com/mozilla/django-csp) if available and configured
1112

1213
## [3.1.1] -- 2024-08-30
1314

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ WEBPACK_LOADER = {
256256

257257
- `CROSSORIGIN`: If you use the `integrity` attribute in your tags and you load your webpack generated assets from another origin (that is not the same `host:port` as the one you load the webpage from), you can configure the `CROSSORIGIN` configuration option. The default value is `''` (empty string), where an empty `crossorigin` attribute will be emitted when necessary. Valid values are: `''` (empty string), `'anonymous'` (functionally same as the empty string) and `use-credentials`. For an explanation, see https://shubhamjain.co/2018/09/08/subresource-integrity-crossorigin/. A typical case for this scenario is when you develop locally and your webpack-dev-server runs with hot-reload on a local host/port other than that of django's `runserver`.
258258

259+
- `CSP_NONCE`: Automatically generate nonces for rendered bundles from [django-csp](https://github.com/mozilla/django-csp). Default `False`. Set this to `True` if you use `django-csp` and and `'strict-dynamic'` [CSP mode](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#strict-dynamic).
260+
259261
- `LOADER_CLASS` is the fully qualified name of a python class as a string that holds the custom Webpack loader. This is where behavior can be customized as to how the stats file is loaded. Examples include loading the stats file from a database, cache, external URL, etc. For convenience, `webpack_loader.loaders.WebpackLoader` can be extended. The `load_assets` method is likely where custom behavior will be added. This should return the stats file as an object.
260262

261263
Here's a simple example of loading from an external URL:

webpack_loader/config.py

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
# Whenever the global setting for SKIP_COMMON_CHUNKS is changed, please
2424
# update the fallback value in get_skip_common_chunks (utils.py).
2525
'SKIP_COMMON_CHUNKS': False,
26+
# Use nonces from django-csp when available
27+
'CSP_NONCE': False
2628
}
2729
}
2830

webpack_loader/loaders.py

+43-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import os
33
import time
4-
from functools import lru_cache
4+
from functools import cached_property, lru_cache
55
from io import open
66
from typing import Dict, Optional
77
from urllib.parse import urlparse
@@ -21,11 +21,20 @@
2121
_CROSSORIGIN_NO_REQUEST = (
2222
'The crossorigin attribute might be necessary but you did not pass a '
2323
'request object. django_webpack_loader needs a request object to be able '
24-
'to know when to emit the crossorigin attribute on link and script tags.')
24+
'to know when to emit the crossorigin attribute on link and script tags. '
25+
'Bundle name: {chunk_name}')
2526
_CROSSORIGIN_NO_HOST = (
2627
'You have passed the request object but it does not have a "HTTP_HOST", '
2728
'thus django_webpack_loader can\'t know if the crossorigin header will '
28-
'be necessary or not.')
29+
'be necessary or not. Bundle name: {chunk_name}')
30+
_NONCE_NO_REQUEST = (
31+
'You have enabled the adding of nonce attributes to generated tags via '
32+
'django_webpack_loader, but haven\'t passed a request. '
33+
'Bundle name: {chunk_name}')
34+
_NONCE_NO_CSPNONCE = (
35+
'django_webpack_loader can\'t generate a nonce tag for a bundle, '
36+
'because the passed request doesn\'t contain a "csp_nonce". '
37+
'Bundle name: {chunk_name}')
2938

3039

3140
@lru_cache(maxsize=100)
@@ -63,31 +72,33 @@ def get_asset_by_source_filename(self, name):
6372
return next((x for x in files if x.get("sourceFilename") == name), None)
6473

6574
def _add_crossorigin(
66-
self, request: Optional[HttpRequest], chunk_url: str,
67-
integrity: str, attrs: str) -> str:
75+
self, request: Optional[HttpRequest], chunk: Dict[str, str],
76+
integrity: str, attrs_l: str) -> str:
6877
'Return an added `crossorigin` attribute if necessary.'
6978
def_value = f' integrity="{integrity}" '
70-
cfgval: str = self.config.get('CROSSORIGIN')
7179
if not request:
72-
warn(message=_CROSSORIGIN_NO_REQUEST, category=RuntimeWarning)
80+
message = _CROSSORIGIN_NO_REQUEST.format(chunk_name=chunk['name'])
81+
warn(message=message, category=RuntimeWarning)
7382
return def_value
74-
if 'crossorigin' in attrs.lower():
83+
if 'crossorigin' in attrs_l:
7584
return def_value
7685
host: Optional[str] = request.META.get('HTTP_HOST')
7786
if not host:
78-
warn(message=_CROSSORIGIN_NO_HOST, category=RuntimeWarning)
87+
message = _CROSSORIGIN_NO_HOST.format(chunk_name=chunk['name'])
88+
warn(message=message, category=RuntimeWarning)
7989
return def_value
80-
netloc = _get_netloc(url=chunk_url)
90+
netloc = _get_netloc(url=chunk['url'])
8191
if netloc == '' or netloc == host:
8292
# Crossorigin not necessary
8393
return def_value
94+
cfgval: str = self.config.get('CROSSORIGIN')
8495
if cfgval == '':
8596
return f'{def_value}crossorigin '
8697
return f'{def_value}crossorigin="{cfgval}" '
8798

8899
def get_integrity_attr(
89100
self, chunk: Dict[str, str], request: Optional[HttpRequest],
90-
attrs: str):
101+
attrs_l: str) -> str:
91102
if not self.config.get('INTEGRITY'):
92103
# Crossorigin only necessary when integrity is used
93104
return ' '
@@ -100,8 +111,27 @@ def get_integrity_attr(
100111
'you forgot to add integrity: true in your '
101112
'BundleTrackerPlugin configuration?')
102113
return self._add_crossorigin(
103-
request=request, chunk_url=chunk['url'], integrity=integrity,
104-
attrs=attrs)
114+
request=request, chunk=chunk, integrity=integrity,
115+
attrs_l=attrs_l)
116+
117+
def get_nonce_attr(
118+
self, chunk: Dict[str, str], request: Optional[HttpRequest],
119+
attrs: str) -> str:
120+
'Return an added nonce for CSP when available.'
121+
if not self.config.get('CSP_NONCE'):
122+
return ''
123+
if request is None:
124+
message = _NONCE_NO_REQUEST.format(chunk_name=chunk['name'])
125+
warn(message=message, category=RuntimeWarning)
126+
return ''
127+
nonce = getattr(request, 'csp_nonce', None)
128+
if nonce is None:
129+
message = _NONCE_NO_CSPNONCE.format(chunk_name=chunk['name'])
130+
warn(message=message, category=RuntimeWarning)
131+
return ''
132+
if 'nonce=' in attrs.lower():
133+
return ''
134+
return f'nonce="{nonce}" '
105135

106136
def filter_chunks(self, chunks):
107137
filtered_chunks = []

webpack_loader/utils.py

+12-11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from collections import OrderedDict
2+
from functools import lru_cache
23
from importlib import import_module
34
from django.conf import settings
45
from .config import load_config
56

6-
_loaders = {}
7-
87

98
def import_string(dotted_path):
109
'''
@@ -21,12 +20,11 @@ def import_string(dotted_path):
2120
raise ImportError('%s doesn\'t look like a valid module path' % dotted_path)
2221

2322

23+
@lru_cache(maxsize=None)
2424
def get_loader(config_name):
25-
if config_name not in _loaders:
26-
config = load_config(config_name)
27-
loader_class = import_string(config['LOADER_CLASS'])
28-
_loaders[config_name] = loader_class(config_name, config)
29-
return _loaders[config_name]
25+
config = load_config(config_name)
26+
loader_class = import_string(config['LOADER_CLASS'])
27+
return loader_class(config_name, config)
3028

3129

3230
def get_skip_common_chunks(config_name):
@@ -73,6 +71,7 @@ def get_as_url_to_tag_dict(
7371
loader = get_loader(config)
7472
bundle = _get_bundle(loader, bundle_name, extension)
7573
result = OrderedDict()
74+
attrs_l = attrs.lower()
7675

7776
for chunk in bundle:
7877
if chunk['name'].endswith(('.js', '.js.gz')):
@@ -82,20 +81,22 @@ def get_as_url_to_tag_dict(
8281
).format(''.join([chunk['url'], suffix]), attrs)
8382
else:
8483
result[chunk['url']] = (
85-
'<script src="{0}"{2}{1}></script>'
84+
'<script src="{0}"{2}{3}{1}></script>'
8685
).format(
8786
''.join([chunk['url'], suffix]),
8887
attrs,
89-
loader.get_integrity_attr(chunk, request, attrs),
88+
loader.get_integrity_attr(chunk, request, attrs_l),
89+
loader.get_nonce_attr(chunk, request, attrs_l),
9090
)
9191
elif chunk['name'].endswith(('.css', '.css.gz')):
9292
result[chunk['url']] = (
93-
'<link href="{0}" rel={2}{3}{1}/>'
93+
'<link href="{0}" rel={2}{3}{4}{1}/>'
9494
).format(
9595
''.join([chunk['url'], suffix]),
9696
attrs,
9797
'"stylesheet"' if not is_preload else '"preload" as="style"',
98-
loader.get_integrity_attr(chunk, request, attrs),
98+
loader.get_integrity_attr(chunk, request, attrs_l),
99+
loader.get_nonce_attr(chunk, request, attrs_l),
99100
)
100101
return result
101102

0 commit comments

Comments
 (0)