diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 270068b..16cf28d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,6 +8,14 @@ Next version
~~~~~~~~~~~~
- Added a ``static_lazy`` helper.
+- Added full CSP support for all object-based media classes:
+ - Added ``attrs`` parameter to ``CSS``, ``JSON``, and updated ``ImportMap`` constructor to accept attributes
+ - All classes now support adding a ``nonce`` attribute for CSP security
+- Added comprehensive CSP support through the ``js_asset.contrib.csp`` module:
+ - Added ``CSPMedia`` class for automatic nonce application
+ - Added ``CSPMediaMixin`` for convenient widget integration
+ - Added ``CSPNonceMiddleware`` for automatic nonce generation
+ - Added ``csp_context_processor`` for template integration
3.1 (2025-02-28)
diff --git a/README.rst b/README.rst
index 691627b..7e64d8e 100644
--- a/README.rst
+++ b/README.rst
@@ -14,7 +14,7 @@ Usage
=====
Use this to insert a script tag via ``forms.Media`` containing additional
-attributes (such as ``id`` and ``data-*`` for CSP-compatible data
+attributes (such as ``id``, ``nonce`` for CSP support, and ``data-*`` for CSP-compatible data
injection.):
.. code-block:: python
@@ -25,6 +25,7 @@ injection.):
JS("asset.js", {
"id": "asset-script",
"data-answer": "42",
+ "nonce": "{{ request.csp_nonce }}", # For CSP support
}),
])
@@ -34,7 +35,7 @@ now contain a script tag as follows, without line breaks:
.. code-block:: html
+ data-answer="42" id="asset-script" nonce="random-nonce-value">
The attributes are automatically escaped. The data attributes may now be
accessed inside ``asset.js``:
@@ -65,21 +66,24 @@ So, you can add everything at once:
from js_asset import CSS, JS, JSON
+ # Get the CSP nonce from the request context
+ nonce = request.csp_nonce
+
forms.Media(js=[
- JSON({"configuration": 42}, id="widget-configuration"),
- CSS("widget/style.css"),
- CSS("p{color:red;}", inline=True),
- JS("widget/script.js", {"type": "module"}),
+ JSON({"configuration": 42}, id="widget-configuration", attrs={"nonce": nonce}),
+ CSS("widget/style.css", attrs={"nonce": nonce}),
+ CSS("p{color:red;}", inline=True, attrs={"nonce": nonce}),
+ JS("widget/script.js", {"type": "module", "nonce": nonce}),
])
This produces:
.. code-block:: html
-
-
-
-
+
+
+
+
@@ -93,6 +97,77 @@ At the time of writing this app is compatible with Django 4.2 and better
definitive answers.
+Content Security Policy (CSP) Support
+====================================
+
+django-js-asset provides comprehensive support for Content Security Policy (CSP)
+through the use of nonce attributes. This feature is available in two ways:
+
+1. Individual asset objects can accept nonce attributes as shown above.
+
+2. Automatic CSP support through the CSPMedia class (recommended):
+
+.. code-block:: python
+
+ # In your settings.py
+ MIDDLEWARE = [
+ # ...
+ 'js_asset.contrib.csp.CSPNonceMiddleware',
+ # ...
+ ]
+
+ TEMPLATES = [
+ {
+ # ...
+ 'OPTIONS': {
+ 'context_processors': [
+ # ...
+ 'js_asset.contrib.csp.csp_context_processor',
+ ],
+ },
+ },
+ ]
+
+ # Optional CSP settings
+ CSP_ENABLED = True
+ CSP_NONCE_LENGTH = 16
+ CSP_DEFAULT_SRC = ["'self'"]
+ CSP_SCRIPT_SRC = ["'self'"]
+ CSP_STYLE_SRC = ["'self'"]
+
+Then use CSPMedia in your forms/widgets:
+
+.. code-block:: python
+
+ from js_asset import CSPMediaMixin, get_csp_media, apply_csp_nonce
+ from django.forms import Media
+
+ # Option 1: Use get_csp_media helper (recommended)
+ def media(self):
+ return get_csp_media(js=['script.js'], css={'all': ['style.css']})
+
+ # Option 2: Use apply_csp_nonce with an existing Media object
+ def media(self):
+ base_media = Media(js=['script.js'], css={'all': ['style.css']})
+ return apply_csp_nonce(base_media, request.csp_nonce)
+
+ # Option 3: Use the CSPMediaMixin in your widget (easiest)
+ class MyWidget(CSPMediaMixin, forms.Widget):
+ class Media:
+ js = ['script.js']
+ css = {'all': ['style.css']}
+
+The middleware will automatically:
+
+1. Generate a unique nonce for each request
+2. Make it available as request.csp_nonce
+3. Add it to all script and style tags in your media
+4. Optionally add a Content-Security-Policy header with the nonce
+
+This approach is particularly useful for automatically adding CSP nonces to existing
+widgets and forms without having to modify their Media declarations.
+
+
Extremely experimental importmap support
========================================
@@ -152,10 +227,15 @@ widget classes for the admin than for the rest of your site.
.. code-block:: python
- # Example for adding a code.js JavaScript *module*
+ # Example for adding a code.js JavaScript *module* with CSP support
+ nonce = request.csp_nonce
+
+ # Create importmap with CSP nonce
+ importmap_with_nonce = ImportMap(importmap._importmap, {"nonce": nonce})
+
forms.Media(js=[
- importmap, # See paragraph above!
- JS("code.js", {"type": "module"}),
+ importmap_with_nonce, # See paragraph above!
+ JS("code.js", {"type": "module", "nonce": nonce}),
])
The code in ``code.js`` can now use a JavaScript import to import assets from
diff --git a/js_asset/__init__.py b/js_asset/__init__.py
index 9863b51..64ce91b 100644
--- a/js_asset/__init__.py
+++ b/js_asset/__init__.py
@@ -5,3 +5,16 @@
with contextlib.suppress(ImportError):
from js_asset.js import * # noqa: F403
+
+# Optional CSP support
+try:
+ from js_asset.contrib.csp import ( # noqa: F401
+ CSPMediaMixin,
+ CSPNonceMiddleware,
+ apply_csp_nonce,
+ csp_context_processor,
+ csp_nonce,
+ get_csp_media,
+ )
+except ImportError:
+ pass
diff --git a/js_asset/contrib/__init__.py b/js_asset/contrib/__init__.py
new file mode 100644
index 0000000..c1c4875
--- /dev/null
+++ b/js_asset/contrib/__init__.py
@@ -0,0 +1 @@
+# Empty init file to make contrib a package
diff --git a/js_asset/contrib/csp.py b/js_asset/contrib/csp.py
new file mode 100644
index 0000000..ccb999e
--- /dev/null
+++ b/js_asset/contrib/csp.py
@@ -0,0 +1,238 @@
+from django.forms import Media
+from django.utils.functional import LazyObject
+
+from ..js import CSS, JS, JSON, ImportMap
+
+
+def apply_nonce_to_js(js_list, nonce):
+ """Apply nonce to a list of JS assets."""
+ result = []
+ for js in js_list:
+ if isinstance(js, JS) and nonce and "nonce" not in js.attrs:
+ # Create copy with updated attrs
+ js_copy = JS(js.src, js.attrs.copy())
+ js_copy.attrs["nonce"] = nonce
+ result.append(js_copy)
+ elif isinstance(js, JSON) and nonce and "nonce" not in js.attrs:
+ # Create copy with updated attrs
+ js_copy = JSON(js.data.copy(), id=js.id, attrs=js.attrs.copy())
+ js_copy.attrs["nonce"] = nonce
+ result.append(js_copy)
+ elif isinstance(js, ImportMap) and nonce and not js._attrs.get("nonce"):
+ # Create copy with updated attrs
+ js_copy = ImportMap(
+ js._importmap.copy(), attrs=js._attrs.copy() if js._attrs else {}
+ )
+ js_copy._attrs["nonce"] = nonce
+ result.append(js_copy)
+ elif isinstance(js, str) and nonce:
+ # Wrap string paths in JS objects with nonce
+ result.append(JS(js, {"nonce": nonce}))
+ else:
+ result.append(js)
+ return result
+
+
+def apply_nonce_to_css(css_dict, nonce):
+ """Apply nonce to a dict of CSS assets."""
+ result = {}
+ for medium, sublist in css_dict.items():
+ new_sublist = []
+ for css in sublist:
+ if isinstance(css, CSS) and nonce and "nonce" not in css.attrs:
+ # Create copy with updated attrs
+ css_copy = CSS(
+ css.src, inline=css.inline, media=css.media, attrs=css.attrs.copy()
+ )
+ css_copy.attrs["nonce"] = nonce
+ new_sublist.append(css_copy)
+ elif isinstance(css, str) and nonce:
+ # Wrap string paths in CSS objects with nonce
+ new_sublist.append(CSS(css, attrs={"nonce": nonce}))
+ else:
+ new_sublist.append(css)
+ result[medium] = new_sublist
+ return result
+
+
+def apply_csp_nonce(media, nonce):
+ """Apply CSP nonce to all media elements in a Media object."""
+ if not media or not nonce:
+ return media
+
+ # Create new media with nonce applied to all elements
+ js_with_nonce = apply_nonce_to_js(media._js, nonce) if hasattr(media, "_js") else []
+ css_with_nonce = (
+ apply_nonce_to_css(media._css, nonce) if hasattr(media, "_css") else {}
+ )
+
+ # Create new Media object with the modified js and css
+ return Media(js=js_with_nonce, css=css_with_nonce)
+
+
+class CSPNonce(LazyObject):
+ """
+ A lazy object to hold the CSP nonce from the request.
+ Used by the context processor and CSPMediaMixin.
+ """
+
+ def _setup(self):
+ self._wrapped = None
+
+ def __bool__(self):
+ return self._wrapped is not None
+
+
+csp_nonce = CSPNonce()
+
+
+def get_csp_media(media=None, css=None, js=None):
+ """
+ Helper function to create a Media object with CSP nonces applied.
+
+ Usage:
+ # In your form/widget:
+ def media(self):
+ return get_csp_media(css={'all': ['style.css']}, js=['script.js'])
+
+ # Or with an existing Media instance:
+ def media(self):
+ base_media = super().media
+ return get_csp_media(media=base_media)
+ """
+ # Create the base media object
+ if media is not None:
+ base_media = media
+ else:
+ base_media = Media(css=css, js=js)
+
+ # Apply CSP nonce if available
+ if csp_nonce:
+ return apply_csp_nonce(base_media, csp_nonce._wrapped)
+
+ return base_media
+
+
+class CSPMediaMixin:
+ """
+ A mixin to automatically apply CSP nonces to media.
+
+ Usage:
+ class MyWidget(CSPMediaMixin, Widget):
+ class Media:
+ js = ['script.js']
+ css = {'all': ['style.css']}
+ """
+
+ @property
+ def media(self):
+ # Get the base media from the parent class
+ if hasattr(super(), "media"):
+ base_media = super().media
+ else:
+ # Fall back to Media class definition if available
+ base_media = Media()
+ if hasattr(self, "Media"):
+ if hasattr(self.Media, "js"):
+ base_media = Media(js=self.Media.js)
+ if hasattr(self.Media, "css"):
+ if base_media._js:
+ base_media = base_media + Media(css=self.Media.css)
+ else:
+ base_media = Media(css=self.Media.css)
+
+ # Apply CSP nonce if available
+ if csp_nonce:
+ return apply_csp_nonce(base_media, csp_nonce._wrapped)
+
+ return base_media
+
+
+def csp_context_processor(request):
+ """
+ Context processor to add the CSP nonce to the template context and set the global csp_nonce.
+
+ Add to TEMPLATES settings:
+ 'OPTIONS': {
+ 'context_processors': [
+ 'js_asset.contrib.csp.csp_context_processor',
+ ],
+ },
+ """
+ # If the request has a CSP nonce attribute, set it in the global csp_nonce
+ if hasattr(request, "csp_nonce"):
+ csp_nonce._wrapped = request.csp_nonce
+ return {"csp_nonce": request.csp_nonce}
+ return {}
+
+
+import base64
+import secrets
+
+
+class CSPNonceMiddleware:
+ """
+ Middleware that generates a CSP nonce for each request.
+
+ Add to MIDDLEWARE settings:
+ 'js_asset.contrib.csp.CSPNonceMiddleware',
+
+ Optionally, configure in settings:
+ CSP_NONCE_LENGTH = 16 # Default
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ # Generate nonce
+ from django.conf import settings
+
+ nonce_length = getattr(settings, "CSP_NONCE_LENGTH", 16)
+ nonce = base64.b64encode(secrets.token_bytes(nonce_length)).decode("ascii")
+
+ # Add to request
+ request.csp_nonce = nonce
+
+ # Set global nonce for widgets and forms rendered outside views
+ csp_nonce._wrapped = nonce
+
+ # Get response
+ response = self.get_response(request)
+
+ # Add CSP header if not already present
+ if hasattr(settings, "CSP_ENABLED") and settings.CSP_ENABLED:
+ if not response.has_header("Content-Security-Policy"):
+ # Build a basic CSP policy if CSP_DEFAULT_SRC is defined
+ if hasattr(settings, "CSP_DEFAULT_SRC"):
+ default_src = " ".join(settings.CSP_DEFAULT_SRC)
+ script_src = f"'nonce-{nonce}'"
+ if hasattr(settings, "CSP_SCRIPT_SRC"):
+ script_src = f"{script_src} {' '.join(settings.CSP_SCRIPT_SRC)}"
+ style_src = f"'nonce-{nonce}'"
+ if hasattr(settings, "CSP_STYLE_SRC"):
+ style_src = f"{style_src} {' '.join(settings.CSP_STYLE_SRC)}"
+
+ csp = f"default-src {default_src}; script-src {script_src}; style-src {style_src}"
+
+ # Add additional directives
+ for directive in [
+ "IMG_SRC",
+ "FONT_SRC",
+ "CONNECT_SRC",
+ "FRAME_SRC",
+ "OBJECT_SRC",
+ "MEDIA_SRC",
+ "CHILD_SRC",
+ "FORM_ACTION",
+ "FRAME_ANCESTORS",
+ "WORKER_SRC",
+ "MANIFEST_SRC",
+ ]:
+ if hasattr(settings, f"CSP_{directive}"):
+ value = " ".join(getattr(settings, f"CSP_{directive}"))
+ csp += f"; {directive.lower().replace('_', '-')} {value}"
+
+ response["Content-Security-Policy"] = csp
+
+ return response
diff --git a/js_asset/js.py b/js_asset/js.py
index 49aa63d..2cf3a54 100644
--- a/js_asset/js.py
+++ b/js_asset/js.py
@@ -23,17 +23,32 @@ class CSS:
src: str
inline: bool = field(default=False, kw_only=True)
media: str = "all"
+ attrs: dict[str, Any] = field(default_factory=dict, kw_only=True)
def __hash__(self):
return hash(self.__str__())
def __str__(self):
if self.inline:
- return format_html('', self.media, self.src)
+ if not self.attrs:
+ return format_html('', self.media, self.src)
+ return format_html(
+ '',
+ self.media,
+ mark_safe(flatatt(self.attrs)),
+ self.src,
+ )
+ if not self.attrs:
+ return format_html(
+ '',
+ static_if_relative(self.src),
+ self.media,
+ )
return format_html(
- '',
+ '',
static_if_relative(self.src),
self.media,
+ mark_safe(flatatt(self.attrs)),
)
@@ -59,25 +74,36 @@ def __str__(self):
class JSON:
data: dict[str, Any]
id: str | None = field(default="", kw_only=True)
+ attrs: dict[str, Any] = field(default_factory=dict, kw_only=True)
def __hash__(self):
return hash(self.__str__())
def __str__(self):
- return json_script(self.data, self.id)
+ if not self.attrs:
+ return json_script(self.data, self.id)
+
+ script = json_script(self.data, self.id)
+ # Insert attributes before the closing tag
+ if self.attrs:
+ attrs_str = flatatt(self.attrs)
+ script = script.replace(">", f"{attrs_str}>", 1)
+ return mark_safe(script)
@html_safe
class ImportMap:
- def __init__(self, importmap):
+ def __init__(self, importmap, attrs=None):
self._importmap = importmap
+ self._attrs = attrs or {}
def __str__(self):
if self._importmap:
html = json_script(self._importmap).removeprefix(
'', html
+ )
+ # Note: attribute order may vary but as long as both attributes are present
+ self.assertIn('src="/static/module.js"', html)
+ self.assertIn('type="module"', html)
+ self.assertIn('nonce="test-nonce"', html)
+ self.assertIn(
+ '',
+ html,
+ )
+
+ # Test get_csp_media helper
+ media2 = get_csp_media(
+ js=["script.js", JS("module.js", {"type": "module"})],
+ css={"all": ["style.css"]},
+ )
+ # Set nonce manually since there's no global nonce in the test
+ media2 = apply_csp_nonce(media2, "test-nonce")
+ html2 = str(media2)
+ self.assertIn('nonce="test-nonce"', html2)
+
+ def test_middleware(self):
+ """Test CSPNonceMiddleware adds nonce to request"""
+ request = self.factory.get("/")
+ self.middleware(request)
+
+ # Check if nonce is added to request
+ self.assertTrue(hasattr(request, "csp_nonce"))
+ self.assertIsInstance(request.csp_nonce, str)
+ self.assertGreater(len(request.csp_nonce), 10) # Reasonable nonce length
+
+ # Check if global nonce is set
+ self.assertTrue(bool(csp_nonce))
+ self.assertEqual(csp_nonce._wrapped, request.csp_nonce)
+
+ def test_context_processor(self):
+ """Test context processor adds nonce to context"""
+ request = self.factory.get("/")
+ request.csp_nonce = "test-nonce"
+
+ context = csp_context_processor(request)
+ self.assertEqual(context["csp_nonce"], "test-nonce")
+ self.assertEqual(csp_nonce._wrapped, "test-nonce")
+
+ def test_csp_media_mixin(self):
+ """Test CSPMediaMixin automatically applies nonce"""
+ request = self.factory.get("/")
+ request.csp_nonce = "test-nonce"
+
+ # Update global nonce
+ csp_nonce._wrapped = request.csp_nonce
+
+ # Create widget with mixin
+ widget = CSPWidget()
+ media = widget.media
+
+ # Test rendering
+ html = str(media)
+ self.assertIn('nonce="test-nonce"', html)
+ self.assertIn(
+ '', html
+ )
+ # Note: attribute order may vary but as long as both attributes are present
+ self.assertIn('src="/static/module.js"', html)
+ self.assertIn('type="module"', html)
+ self.assertIn('nonce="test-nonce"', html)
+ self.assertIn(
+ '',
+ html,
+ )
diff --git a/tests/testapp/test_importmap.py b/tests/testapp/test_importmap.py
index 50ef9f6..82c5100 100644
--- a/tests/testapp/test_importmap.py
+++ b/tests/testapp/test_importmap.py
@@ -40,3 +40,17 @@ def test_merging(self):
"""\
""",
)
+
+ def test_csp_nonce(self):
+ # Test with CSP nonce attribute
+ importmap = ImportMap(
+ {
+ "imports": {"a": "/static/a.js"},
+ },
+ attrs={"nonce": "random-nonce"},
+ )
+
+ self.assertEqual(
+ str(importmap),
+ '',
+ )
diff --git a/tests/testapp/test_js_asset.py b/tests/testapp/test_js_asset.py
index 1417839..aef4384 100644
--- a/tests/testapp/test_js_asset.py
+++ b/tests/testapp/test_js_asset.py
@@ -83,6 +83,17 @@ def test_css(self):
'',
)
+ # Test with CSP nonce attribute
+ self.assertEqual(
+ str(CSS("app/style.css", attrs={"nonce": "random-nonce"})),
+ '',
+ )
+
+ self.assertEqual(
+ str(CSS("p{color:red}", inline=True, attrs={"nonce": "random-nonce"})),
+ '',
+ )
+
def test_json(self):
self.assertEqual(
str(JSON({"hello": "world"}, id="hello")),
@@ -93,3 +104,9 @@ def test_json(self):
str(JSON({"hello": "world"})),
'',
)
+
+ # Test with CSP nonce attribute
+ self.assertEqual(
+ str(JSON({"hello": "world"}, id="hello", attrs={"nonce": "random-nonce"})),
+ '',
+ )
diff --git a/tox.ini b/tox.ini
index 23c9599..1c4f9b2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,5 +14,5 @@ deps =
dj42: Django>=4.2,<5.0
dj50: Django>=5.0,<5.1
dj51: Django>=5.1,<5.2
- dj52: Django>=5.2a1,<6.0
+ dj52: Django>=5.2,<6.0
djmain: https://github.com/django/django/archive/main.tar.gz