Opened 4 months ago

Last modified 2 days ago

#36784 assigned New feature

Add CSP support to Django's script object and media objects

Reported by: Johannes Maron Owned by: Johannes Maron
Component: Forms Version: 6.0
Severity: Normal Keywords:
Cc: Johannes Maron, Rob Hudson, Tobias Kunze, David Smith, Carsten Fuchs Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: yes
Easy pickings: no UI/UX: no

Description

Django 5.2 added official support for a script object in media classes #35886

However, the introduction of CSP's nonce-function in Django 6.0 seems to have overlooked both old form media rendering and the script-object.
Furthermore, the template processor-based approach currently doesn't provide practical solution of object based media assets.

I'd suggest updating the media class and tag-rendering to include a nonce values by default, or the least an opt-in that doesn't require the request context in during form definition.

If there already is an easy way to add nonce-values to form media, I'd suggest that we add a few lines of documentation for the next developer looking for it.

Change History (30)

comment:1 by Natalia Bidart, 4 months ago

Type: UncategorizedNew feature

Hello Johannes! Thank you for this report. Could you please share a small project sample that would highlight how CSP is lacking support for the cases you listed? That would help me greatly to properly triage this ticket.

comment:2 by Natalia Bidart, 4 months ago

Cc: Rob Hudson added

Adding Rob as cc for awareness.

comment:3 by Natalia Bidart, 4 months ago

Cc: Tobias Kunze David Smith added
Triage Stage: UnreviewedAccepted

I've been thinking about this and I see a couple of options:

  • A decent workaround would be to define a template filter that would take the nonce and include it in the tag. We could perhaps write a how-to to backport and include in 6.0.
  • For main, I agree that we should ideally have something more "first class citizen" in the objects. I'm adding a few folks as cc to see what they think.

Given the above, I'll accept pending a design discussion for the "new feature" part for 6.1. In any case, Johannes it would be super helpful if you could attach a minimal sample project showing the use cases.

comment:4 by Pravin, 4 months ago

Any thoughts on below failing testcase for above behaviour ?

from django.test import SimpleTestCase, override_settings
from django.forms import Form
from django.template import Context, Template
from django.utils.csp import CSP

class FormWithJsMedia(Form):
    class Media:
        js = ["path/to/js_file.js"]

@override_settings(
    STATIC_URL="/static/",
    MIDDLEWARE=[
        "django.middleware.security.SecurityMiddleware",
        "django.middleware.csp.ContentSecurityPolicyMiddleware",
    ],
    TEMPLATES=[{
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.template.context_processors.csp",
            ],
        },
    }],
    SECURE_CSP={
        "default-src": [CSP.SELF],
        "script-src": [CSP.SELF, CSP.NONCE],
    }
)
class CSPMediaTest(SimpleTestCase):
    def test_form_media_js_missing_nonce(self):
        form = FormWithJsMedia()
        tpl = Template("{% load static %}{{ form.media }}")
        rendered = tpl.render(Context({"form": form}))
        self.assertIn('<script src="/static/path/to/js_file.js">', rendered)
        self.assertIn('nonce="', rendered)

comment:5 by Rish, 4 months ago

Owner: set to Rish
Status: newassigned

comment:6 by Rob Hudson, 4 months ago

The challenge seems to be that form.media does not have access to the context and is stateless. I assume this is by design. Changing this seems like a big architectural shift so I looked for other options.

One idea that I liked has two parts to it:

  1. Extend the Script class to add a with_nonce: bool = False parameter.

Example:

class MyWidget(forms.TextInput):
    class Media:
        js = [
            "already-in-policy.js",  # No nonce needed
            Script("inline-script.js", with_nonce=True),  # Opt-in to nonce
        ]

This would render the script tag with a data attribute - something harmless if the next step is forgotten (vs something like a nonce attribute with a sentinel):

    <script src="..." data-csp-nonce></script>

I like the opt-in nature of this vs outputting all tags with a data attribute since, if the media is self served you likely don't need the nonce.

  1. Use a template filter to replace data attribute with the actual nonce
    {{ form.media|with_nonce }}

The filter:

  • finds and replaces the data-csp-nonce attribute with the actual nonce from template context.
  • if no nonce in the context, removes the data attribute.

comment:7 by Johannes Maron, 4 months ago

@Rish, if you don't mind, I was hoping to solve this myself. Did you make any progress yet, you'd care to share?

@Rob, I was thinking to use template nodes, instead of HTML-safe strings. So the asset objects would be rendered with the full temple context, including a nouce.
If the template includes it, we render it. Otherwise we don't. Of course, this could be added explicitly with a keyword, as you suggested.

in reply to:  7 comment:8 by Rish, 3 months ago

Replying to Johannes Maron:

@Rish, if you don't mind, I was hoping to solve this myself. Did you make any progress yet, you'd care to share?

@Rob, I was thinking to use template nodes, instead of HTML-safe strings. So the asset objects would be rendered with the full temple context, including a nouce.
If the template includes it, we render it. Otherwise we don't. Of course, this could be added explicitly with a keyword, as you suggested.

Sorry for blocking you, I am new to this. You can have the ticket.

comment:9 by Rish, 3 months ago

Owner: Rish removed
Status: assignednew

comment:10 by Nilesh Pahari, 3 months ago

Owner: set to Nilesh Pahari
Status: newassigned

in reply to:  10 ; comment:11 by Laharyy, 3 months ago

Replying to Nilesh Pahari: Hi, I noticed this ticket is currently assigned.

I’m interested in working on this issue and have started reviewing the Media
and script rendering internals. Please let me know if anyone is actively
working on it; otherwise I’d be happy to take this forward.

in reply to:  11 comment:12 by Nilesh Pahari, 3 months ago

Replying to Laharyy:

Replying to Nilesh Pahari: Hi, I noticed this ticket is currently assigned.

I’m interested in working on this issue and have started reviewing the Media
and script rendering internals. Please let me know if anyone is actively
working on it; otherwise I’d be happy to take this forward.

Hi, thanks for your interest. I’m currently working on this, but I’ll definitely let you know if I need any help.

comment:13 by Nilesh Pahari, 3 months ago

Hi @Laharyy, this is taking a bit longer than I initially anticipated. If you’re particularly interested, you’re welcome to take this up. I’m still working on it and may open a PR in the coming days otherwise.

comment:14 by Johannes Maron, 4 weeks ago

Owner: changed from Nilesh Pahari to Johannes Maron

Hi there,

Since I opened it and am fairly familiar with this part of the code base, I am taking the liberty to submit a patch myself. I hope y'all don't mind <3

Cheers!
Joe

comment:15 by Johannes Maron, 4 weeks ago

Has patch: set
Patch needs improvement: set

comment:16 by Johannes Maron, 4 weeks ago

Patch needs improvement: unset

comment:17 by Jacob Walls, 3 weeks ago

Patch needs improvement: set

comment:18 by Johannes Maron, 3 weeks ago

Patch needs improvement: unset

comment:19 by Jacob Walls, 2 weeks ago

Patch needs improvement: set

comment:20 by Natalia Bidart, 2 weeks ago

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

  • django/forms/widgets.py

    diff --git a/django/forms/widgets.py b/django/forms/widgets.py
    index 1bcfeba288..db47f0f1a2 100644
    a b class MediaAsset:  
    8282        return hash(self._path)
    8383
    8484    def __str__(self):
     85        return self.render()
     86
     87    def __repr__(self):
     88        return f"{type(self).__qualname__}({self._path!r})"
     89
     90    def render(self, *, attrs=None):
    8591        return format_html(
    8692            self.element_template,
    8793            path=self.path,
    88             attributes=flatatt(self.attributes),
     94            attributes=flatatt({**(attrs or {}), **self.attributes}),
    8995        )
    9096
    91     def __repr__(self):
    92         return f"{type(self).__qualname__}({self._path!r})"
    93 
    9497    @property
    9598    def path(self):
    9699        """
    class Media:  
    142145    def _js(self):
    143146        return self.merge(*self._js_lists)
    144147
    145     def render(self):
     148    def render(self, *, attrs=None):
    146149        return mark_safe(
    147150            "\n".join(
    148151                chain.from_iterable(
    149                     getattr(self, "render_" + name)() for name in MEDIA_TYPES
     152                    getattr(self, "render_" + name)(attrs=attrs) for name in MEDIA_TYPES
    150153                )
    151154            )
    152155        )
    153156
    154     def render_js(self):
     157    def render_js(self, *, attrs=None):
    155158        return [
    156159            (
    157                 path.__html__()
    158                 if hasattr(path, "__html__")
    159                 else format_html('<script src="{}"></script>', self.absolute_path(path))
     160                path.render(attrs=attrs)
     161                if isinstance(path, MediaAsset)
     162                else (
     163                    path.__html__()
     164                    if hasattr(path, "__html__")
     165                    else Script(self.absolute_path(path)).render(attrs=attrs)
     166                )
    160167            )
    161168            for path in self._js
    162169        ]
    163170
    164     def render_css(self):
     171    def render_css(self, *, attrs=None):
    165172        # To keep rendering order consistent, we can't just iterate over
    166173        # items(). We need to sort the keys, and iterate over the sorted list.
    167174        media = sorted(self._css)
    168175        return chain.from_iterable(
    169176            [
    170177                (
    171                     path.__html__()
    172                     if hasattr(path, "__html__")
    173                     else format_html(
    174                         '<link href="{}" media="{}" rel="stylesheet">',
    175                         self.absolute_path(path),
    176                         medium,
     178                    path.render(attrs=attrs)
     179                    if isinstance(path, MediaAsset)
     180                    else (
     181                        path.__html__()
     182                        if hasattr(path, "__html__")
     183                        else format_html(
     184                            '<link href="{}" media="{}" {} rel="stylesheet">',
     185                            self.absolute_path(path),
     186                            medium,
     187                            flatatt(attrs or {}),
     188                        )
    177189                    )
    178190                )
    179191                for path in self._css[medium]
  • new file django/templatetags/media.py

    diff --git a/django/templatetags/media.py b/django/templatetags/media.py
    new file mode 100644
    index 0000000000..c9c84e9042
    - +  
     1from django import template
     2
     3register = template.Library()
     4
     5
     6@register.filter
     7def with_nonce(media, nonce):
     8    """
     9    Render a Media object with a CSP nonce applied to all script and link tags.
     10
     11    Usage::
     12
     13        {% load media %}
     14        {{ form.media|with_nonce:csp_nonce }}
     15    """
     16    return media.render(attrs={"nonce": nonce} if nonce else None)

comment:21 by Jacob Walls, 10 days ago

I find the reasoning behind Natalia's proposal persuasive, and I also like the parallel explicitness with the explicit provision of {{ csp_nonce }} in <script> tags.

comment:22 by Carsten Fuchs, 5 days ago

Cc: Carsten Fuchs added

comment:23 by Natalia Bidart, 3 days ago

Owner: changed from Johannes Maron to Natalia Bidart

Given that I have a draft branch for the solution I proposed above, I may take ownership of the ticket and polish the PR. @codingjoe I would appreciate if you would be able to provide reviews!

comment:24 by Johannes Maron, 3 days ago

Owner: changed from Natalia Bidart to Johannes Maron

Hi there,

Thank you for your patience. I have a newborn at home and don't get to work on Django every day.

A separate tag was my initial solution before changing it to a more "magical" solution based on community feedback.

Since I only need to revert the changes that I already have, I would very much appreciate continuing to work on this myself. Natalia, please reopen my initial PR so we don't lose the history for any reviewer.

Lastly, I don't feel comfortable moving forward without a decision from the steering committee. There has been too much back and forth on potential solutions.
Especially a filter solution has sizable architectural implications, which I will try to outline for the committee later today.

Best
Joe

comment:25 by Johannes Maron, 3 days ago

Request for Comment: CSP nonce in form media

Goal: We want to provide a way to render form media with CSP nonce values (currently impossible).

There are multiple ways to go about this, all with their pros and cons. We should put a strong emphasis on dev usability (for people with deadlines) while weighing the security considerations (marked with an (S)). Importantly, any approach (other than a tag) may set a precedent and must be considered beyond the scope of this ticket.

I would kindly ask the steering council and the security team for an official comment, including a short explanation for future reference.

Tag-based

{% with_nonce form.media %}
Pros Cons
No changes to the template engine are required. Requires updates to admin templates & 3rd-party packages.
Consistent with current logic, that rendering is handled in tags, like csrf. Fairly code-intense in Django.
(S) Explicit choice to add nonce values to form media.

Filter-based w/ explicit nonce

{% if csp_nonce %}
  {{ form.media|with_nonce:csp_nonce }}
{% endif %}

<!-- or -->

{% if csp_nonce %}
  {{ form.media|context:csp_nonce }}
{% endif %}
Pros Cons
No changes to the template engine are required. Requires updates to admin templates & 3rd-party packages.
(S) Explicit choice to add nonce values to form media. Requires case handling to prevent VariableDoesNotExist in Django's admin and 3rd-party packages. (The context processor needs to be added to a project.)
Fairly complex template syntax, including branching.

Filter-based w/ implicit context

{{ form.media|with_nonce }}
<!-- or -->
{{ form.media|context:"csp_nonce" }}
Pros Cons
(S) Explicit choice to add nonce values to form media. Requires updates to admin templates & 3rd-party packages.
A context filter would be reusable. A context filter would be reusable and requires some form of a public interface.
(S) Requires changes to the template engines. Filters are currently not context-aware.

Automatic injection (if nonce in context)

{{ form.media }}
Pros Cons
No updates to existing templates required (incl. 3rd party). (S) No explicit opt-in nonce in form media. However, the context processor and the nonce option in CSP need to be enabled.
Simple (as is) syntax. (S) Requires a new "special" object that gets rendered with a nonce.

Author's note

I have implemented all versions and don't have a strong preference. Tag-based seems the most in line with Django's current idioms, yet I do like the simplicity of the automatic approach. Both filter approaches come with a fair bit of complexity for either Django or its users, which is why I, personally, wouldn't endorse them. The tag-based implementation was my first approach; however, it does require more new code than any other solution (similar to CSRF).

comment:26 by Jacob Walls, 3 days ago

Can I ask for some clarification on the security angle? It seems all of the "cons" reduce to the proposition that we trust at least some portion of the request.context. (Either potentially *any* key with a reusable context filter, or just a hard-coded key for csp nonces.) I thought request.context was already trusted, so I'm having to grasp at straws to imagine the concern: is it that in a stack of reusable apps not totally under your control, you cannot reason about whether or not the contents of request.context have been sanitized if user-controlled? Or something else?


I'm glad to have the Steering Council's eyes here to ratify an approach, as I'm very keen to advance this for 6.1 (feature freeze mid-May).

comment:27 by Timothy Schilling, 2 days ago

I think for most Django devs who are not super familiar with CSP, but still want to build out robust applications, avoiding {{ form.media|with_nonce:csp_nonce }} would be ideal. On the Django Discord, there were several long-time django devs who agreed that the automatic injection of the nonce would be a preferable API, with nobody preferring the explicit template tag.

comment:28 by Johannes Maron, 2 days ago

Hi Jacob,

hm… I hoped to point to more cons than just security considerations in my previous comment. If you are referring to my deliberation on the PR, yes, that one is a little trickier.
I believe my point isn't "can we trust the context" (We don't; that's why we're escaping template variables.) – It's rather, can we trust everyone with the context? A radical example would be changing __html__ to always ingest the full context. I would consider this VERY unsafe (to an injection attack). We should always explicitly limit the context, but there are multiple ways to do it.

Unrelated to the context, my other concern was that you might not trust a 3rd party to use safe script sources. I may trust the package, but not its supply chain. E.G., Django's GeoAdmin uses scripts from a CDN, which opens the door to a supply chain attack. The automatic solution would be to unknowingly trust those resources. It does go against the idea of CSP a little, where you want to explicitly review and whitelist browser resources.

My main concern with any solution is making any changes to the template engine. Both Django and Jinja had their fair share of security vulnerabilities in their early days. New code simply means a new opportunity to screw up. I consider myself experienced enough to know that I make mistakes :P

Lastly, yes, 6.1 would be great, since this has been a bit of a pain point in multiple issues. But Django has thought of patience. ;)

Hi Tim,

Yes, that's why I emphasized the "people with deadlines" part. It should be easy to do the right (secure) thing. Now we just need to figure out what that is.

Cheers,
Joe

comment:29 by Natalia Bidart, 2 days ago

Thank you everyone for the further commentary.

Given that there is a closely related ticket (#36825) covering the same problem space from the admin's angle, and that Trac is not the best venue to gather broad community input on a design question of this scope (audience here is very limited), I will open a Django Forum post to consolidate and continue the discussion. A link will be added here once it is up, and I would kindly ask everyone to follow up there. The Security team and SC are present on that platform and more "pingeable", while the broader community can also contribute to the conversation.

Note: See TracTickets for help on using tickets.
Back to Top