Skip to content

Add CROSSORIGIN and django-csp handling #413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ workflows:
- base-test:
matrix:
parameters:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["3.2", "4.2", "5.0", "5.1"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
django-version: ["4.2", "5.0", "5.1", "5.2"]
exclude:
- python-version: "3.8"
django-version: "5.0"
Expand All @@ -17,6 +17,10 @@ workflows:
django-version: "5.1"
- python-version: "3.9"
django-version: "5.1"
- python-version: "3.8"
django-version: "5.2"
- python-version: "3.9"
django-version: "5.2"
- coverall:
requires:
- base-test
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` and `<link>` tags. Integrity hash is get from stats file and configuration on side of `BundleTracker`, where [configuration option](https://github.com/django-webpack/webpack-bundle-tracker#options) `integrity: true` is required.
- `INTEGRITY` is a flag enabling [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) on rendered `<script>` and `<link>` tags. Integrity hash is fetched from the stats of `BundleTrackerPlugin`. The [configuration option](https://github.com/django-webpack/webpack-bundle-tracker#options) `integrity: true` is required.

- `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`.

- `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).

- `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.

Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
coverage==5.5
twine==3.4.2
Django==3.2.25
Django==5.2.1
django-jinja==2.10.2
unittest2==1.1.0
wheel==0.38.1
195 changes: 182 additions & 13 deletions tests/app/tests/test_webpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
from shutil import rmtree
from subprocess import call
from threading import Thread
from unittest.mock import Mock
from unittest.mock import call as MockCall
from unittest.mock import patch
from unittest.mock import call as MockCall

from django.conf import settings
from django.template import Context, Template, engines
Expand All @@ -18,11 +17,14 @@
from django_jinja.backend import Template as Jinja2Template
from django_jinja.builtins import DEFAULT_EXTENSIONS

from webpack_loader.exceptions import (WebpackBundleLookupError, WebpackError,
WebpackLoaderBadStatsError,
WebpackLoaderTimeoutError)
from webpack_loader.exceptions import (
WebpackBundleLookupError,
WebpackError,
WebpackLoaderBadStatsError,
WebpackLoaderTimeoutError,
)
from webpack_loader.templatetags.webpack_loader import _WARNING_MESSAGE
from webpack_loader.utils import get_as_tags, get_loader
from webpack_loader.utils import get_as_tags, get_loader, get_as_url_to_tag_dict

BUNDLE_PATH = os.path.join(
settings.BASE_DIR, 'assets/django_webpack_loader_bundles/')
Expand Down Expand Up @@ -217,18 +219,112 @@ def test_integrity(self):
self.compile_bundles('webpack.config.integrity.js')

loader = get_loader(DEFAULT_CONFIG)
with patch.dict(loader.config, {'INTEGRITY': True}):
with patch.dict(loader.config, {'INTEGRITY': True, 'CACHE': False}):
view = TemplateView.as_view(template_name='single.html')
request = self.factory.get('/')
result = view(request)

self.assertIn((
'<script src="/static/django_webpack_loader_bundles/main.js" '
'integrity="sha256-1wgFMxcDlOWYV727qRvWNoPHdnOGFNVMLuKd25cjR+o=" >'
'<script src="http://custom-static-host.com/main.js" '
'integrity="sha256-Yk6uAc7SoE41LSNc9zTBxij8YhVqBIIuRpLCaTyqrl'
'Q= sha384-cwtz5c2CaEK8Q8ZeraWgf3qo7eO5jUDE8XMo00QTUCcbmF/fLu'
'DtQFm8g4Jh9R5D sha512-s9uhbJTCZv4WfH/F81fgS6B6XNhOuH21Xouv5X'
'Pp35WlFR7ykkIafUG8cma4vbEfheH1NVbjsON5BHm8U13I4g==" >'
'</script>'), result.rendered_content)
self.assertIn((
'<link href="/static/django_webpack_loader_bundles/main.css" rel="stylesheet" '
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30=" />'),
'<link href="http://custom-static-host.com/main.css" '
'rel="stylesheet" integrity="sha256-cYWwRvS04/VsttQYx4BalKYrB'
'Duw5t8vKFhWB/LKX30= sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI'
'01UR8wKIFkIr6vEaT5YRaeLMfLcAQvS sha512-aigPxglXDA33t9s5i0vRa'
'p5b7dFwyp7cSN6x8rOXrPpCTMubOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBH'
'KLQPw==" />'),
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='single.html')
request = self.factory.get('/')
request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
result = view(request)

self.assertIn((
'<script src="http://custom-static-host.com/main.js" '
'integrity="sha256-Yk6uAc7SoE41LSNc9zTBxij8YhVqBIIuRpLCaTyqrlQ= '
'sha384-cwtz5c2CaEK8Q8ZeraWgf3qo7eO5jUDE8XMo00QTUCcbmF/fLuDtQFm8'
'g4Jh9R5D sha512-s9uhbJTCZv4WfH/F81fgS6B6XNhOuH21Xouv5XPp35WlFR7'
'ykkIafUG8cma4vbEfheH1NVbjsON5BHm8U13I4g==" '
'crossorigin ></script>'
), result.rendered_content)
self.assertIn((
'<link href="http://custom-static-host.com/main.css" '
'rel="stylesheet" '
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30= '
'sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI01UR8wKIFkIr6vEaT5YRaeL'
'MfLcAQvS sha512-aigPxglXDA33t9s5i0vRap5b7dFwyp7cSN6x8rOXrPpCTMu'
'bOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBHKLQPw==" '
'crossorigin />'),
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='single.html')
request = self.factory.get('/')
request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
result = view(request)

self.assertIn((
'<script src="http://custom-static-host.com/main.js" '
'integrity="sha256-Yk6uAc7SoE41LSNc9zTBxij8YhVqBIIuRpLCaTyqrlQ= '
'sha384-cwtz5c2CaEK8Q8ZeraWgf3qo7eO5jUDE8XMo00QTUCcbmF/fLuDtQFm8'
'g4Jh9R5D sha512-s9uhbJTCZv4WfH/F81fgS6B6XNhOuH21Xouv5XPp35WlFR7'
'ykkIafUG8cma4vbEfheH1NVbjsON5BHm8U13I4g==" '
'crossorigin="anonymous" ></script>'
), result.rendered_content)
self.assertIn((
'<link href="http://custom-static-host.com/main.css" '
'rel="stylesheet" '
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30= '
'sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI01UR8wKIFkIr6vEaT5YRaeL'
'MfLcAQvS sha512-aigPxglXDA33t9s5i0vRap5b7dFwyp7cSN6x8rOXrPpCTMu'
'bOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBHKLQPw==" '
'crossorigin="anonymous" />'),
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='single.html')
request = self.factory.get('/')
request.META['HTTP_HOST'] = 'crossorigin-custom-static-host.com'
result = view(request)

self.assertIn((
'<script src="http://custom-static-host.com/main.js" '
'integrity="sha256-Yk6uAc7SoE41LSNc9zTBxij8YhVqBIIuRpLCaTyqrlQ= '
'sha384-cwtz5c2CaEK8Q8ZeraWgf3qo7eO5jUDE8XMo00QTUCcbmF/fLuDtQFm8'
'g4Jh9R5D sha512-s9uhbJTCZv4WfH/F81fgS6B6XNhOuH21Xouv5XPp35WlFR7'
'ykkIafUG8cma4vbEfheH1NVbjsON5BHm8U13I4g==" '
'crossorigin="use-credentials" ></script>'
), result.rendered_content)
self.assertIn((
'<link href="http://custom-static-host.com/main.css" '
'rel="stylesheet" '
'integrity="sha256-cYWwRvS04/VsttQYx4BalKYrBDuw5t8vKFhWB/LKX30= '
'sha384-V/UxbrsEy8BK5nd+sBlN31Emmq/WdDDdI01UR8wKIFkIr6vEaT5YRaeL'
'MfLcAQvS sha512-aigPxglXDA33t9s5i0vRap5b7dFwyp7cSN6x8rOXrPpCTMu'
'bOR7qTFpmTIa8z9B0wtXxbSheBPNCEURBHKLQPw==" '
'crossorigin="use-credentials" />'),
result.rendered_content
)

Expand All @@ -244,11 +340,11 @@ def test_integrity_missing_config(self):
result = view(request)

self.assertIn((
'<script src="/static/django_webpack_loader_bundles/main.js" >'
'<script src="http://custom-static-host.com/main.js" >'
'</script>'), result.rendered_content
)
self.assertIn((
'<link href="/static/django_webpack_loader_bundles/main.css" rel="stylesheet" />'),
'<link href="http://custom-static-host.com/main.css" rel="stylesheet" />'),
result.rendered_content
)

Expand Down Expand Up @@ -850,3 +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."""

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'])

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')

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"

# JavaScript file
tag_dict = get_as_url_to_tag_dict('main', extension='js', attrs='', request=request)
self.assertIn('<script src="/static/django_webpack_loader_bundles/main.js"',
tag_dict['/static/django_webpack_loader_bundles/main.js'])
self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.js'])

# CSS file
tag_dict = get_as_url_to_tag_dict('main', extension='css', attrs='', request=request)
self.assertIn('<link href="/static/django_webpack_loader_bundles/main.css" rel="stylesheet"',
tag_dict['/static/django_webpack_loader_bundles/main.css'])
self.assertIn('nonce="test-nonce-123"', tag_dict['/static/django_webpack_loader_bundles/main.css'])
1 change: 1 addition & 0 deletions tests/webpack.config.integrity.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
context: __dirname,
entry: './assets/js/index',
output: {
publicPath: 'http://custom-static-host.com/',
path: path.resolve('./assets/django_webpack_loader_bundles/'),
filename: "[name].js"
},
Expand Down
4 changes: 3 additions & 1 deletion tests_webpack5/app/templates/home.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% load webpack_asset from webpack_loader %}
{% load render_bundle webpack_asset from webpack_loader %}
<!DOCTYPE html>
<html>
<head>
Expand All @@ -7,6 +7,8 @@
</head>

<body>
<div id="react-app"></div>
<a href="{% webpack_asset 'assets/js/resource.txt' %}">Download resource</a>
{% render_bundle 'resources' 'js' %}
</body>
</html>
Loading