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