#36784: Add CSP support to Django's script object and media objects
--------------------------------+------------------------------------------
Reporter: Johannes Maron | Owner: Johannes Maron
Type: New feature | Status: assigned
Component: Forms | Version: 6.0
Severity: Normal | Resolution:
Keywords: | Triage Stage: Accepted
Has patch: 1 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 1
Easy pickings: 0 | UI/UX: 0
--------------------------------+------------------------------------------
Comment (by Natalia Bidart):
I've left a note on the PR, but wanted to expand here on the design
question. My recommendation is to go with a more explicit approach: I've
been brainstorming with some LLMs and investigating different paths. The
one I settled on is having a `render(attrs=None)` method on `Script` and
`Media` classes, paired with a template filter that passes the nonce
explicitly. This keeps CSP and form media as independent concerns (which I
think it's important), the rendering machinery stays generic, and the
filter is the only place that knows a nonce is involved and connects the
two worlds.
My rationale is that both CSP and form media are opt-in features, and I
think they should be combined explicitly and not behind the scenes, this
is why I propose a filter since it makes the intent visible at the call
site. Rob's suggestion in comment:6 points in the right direction, though
I'd build on it as follows: rather than a boolean flag on `Script`, let's
add a generic `attrs` dict parameter in `render`, since this is more
consistent with `Widget.render(attrs=...)` elsewhere in `django.forms`,
and is more extensible (and nonce-agnostic). Then the filter bridges both
sides:
{{{
{{ form.media|with_nonce:csp_nonce }}
}}}
On the filter vs. tag question: yes, a filter cannot access the template
context directly, so the nonce must be passed explicitly as the filter
argument. For me, that's actually a (required) feature: it makes the
machinery explicit and works regardless of what the variable is named in
the context (think about an alternative implementation of CSP or a
different nonce generator).
Rough sketch (names and logic to be polished):
{{{#!diff
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 1bcfeba288..db47f0f1a2 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -82,15 +82,18 @@ class MediaAsset:
return hash(self._path)
def __str__(self):
+ return self.render()
+
+ def __repr__(self):
+ return f"{type(self).__qualname__}({self._path!r})"
+
+ def render(self, *, attrs=None):
return format_html(
self.element_template,
path=self.path,
- attributes=flatatt(self.attributes),
+ attributes=flatatt({**(attrs or {}), **self.attributes}),
)
- def __repr__(self):
- return f"{type(self).__qualname__}({self._path!r})"
-
@property
def path(self):
"""
@@ -142,38 +145,47 @@ class Media:
def _js(self):
return self.merge(*self._js_lists)
- def render(self):
+ def render(self, *, attrs=None):
return mark_safe(
"\n".join(
chain.from_iterable(
- getattr(self, "render_" + name)() for name in
MEDIA_TYPES
+ getattr(self, "render_" + name)(attrs=attrs) for name
in MEDIA_TYPES
)
)
)
- def render_js(self):
+ def render_js(self, *, attrs=None):
return [
(
- path.__html__()
- if hasattr(path, "__html__")
- else format_html('<script src="{}"></script>',
self.absolute_path(path))
+ path.render(attrs=attrs)
+ if isinstance(path, MediaAsset)
+ else (
+ path.__html__()
+ if hasattr(path, "__html__")
+ else
Script(self.absolute_path(path)).render(attrs=attrs)
+ )
)
for path in self._js
]
- def render_css(self):
+ def render_css(self, *, attrs=None):
# To keep rendering order consistent, we can't just iterate over
# items(). We need to sort the keys, and iterate over the sorted
list.
media = sorted(self._css)
return chain.from_iterable(
[
(
- path.__html__()
- if hasattr(path, "__html__")
- else format_html(
- '<link href="{}" media="{}" rel="stylesheet">',
- self.absolute_path(path),
- medium,
+ path.render(attrs=attrs)
+ if isinstance(path, MediaAsset)
+ else (
+ path.__html__()
+ if hasattr(path, "__html__")
+ else format_html(
+ '<link href="{}" media="{}" {}
rel="stylesheet">',
+ self.absolute_path(path),
+ medium,
+ flatatt(attrs or {}),
+ )
)
)
for path in self._css[medium]
diff --git a/django/templatetags/media.py b/django/templatetags/media.py
new file mode 100644
index 0000000000..c9c84e9042
--- /dev/null
+++ b/django/templatetags/media.py
@@ -0,0 +1,16 @@
+from django import template
+
+register = template.Library()
+
+
+...@register.filter
+def with_nonce(media, nonce):
+ """
+ Render a Media object with a CSP nonce applied to all script and link
tags.
+
+ Usage::
+
+ {% load media %}
+ {{ form.media|with_nonce:csp_nonce }}
+ """
+ return media.render(attrs={"nonce": nonce} if nonce else None)
}}}
--
Ticket URL: <
https://code.djangoproject.com/ticket/36784#comment:20>