From 89b51bf17d0c1ccfbd89609d7e8f5fda9dff2f34 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 26 Feb 2025 16:24:23 -0600 Subject: [PATCH 1/5] Rely on django-csp's private attribute for nonce This refactors how the CSP nonce is fetched. It's now done as a toolbar property and wraps the private attribute request._csp_nonce This avoids the toolbar from generating a nonce that gets injected into the CSP header when the view doesn't expect it to. It also supports using a nonce that is generated from any other point while processing the request, including other middleware. --- .../templates/debug_toolbar/base.html | 6 +- .../debug_toolbar/includes/panel_content.html | 2 +- .../templates/debug_toolbar/redirect.html | 2 +- debug_toolbar/toolbar.py | 14 ++ docs/changes.rst | 1 + tests/test_csp_rendering.py | 144 +++++++++++------- tests/urls.py | 1 + tests/views.py | 5 + 8 files changed, 115 insertions(+), 60 deletions(-) diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index b0308be55..a9983250d 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -1,10 +1,10 @@ {% load i18n static %} {% block css %} - - + + {% endblock %} {% block js %} - + {% endblock %}
{{ panel.title }}
{% if toolbar.should_render_panels %} - {% for script in panel.scripts %}{% endfor %} + {% for script in panel.scripts %}{% endfor %}
{{ panel.content }}
{% else %}
diff --git a/debug_toolbar/templates/debug_toolbar/redirect.html b/debug_toolbar/templates/debug_toolbar/redirect.html index cb6b4a6ea..9d8966ed7 100644 --- a/debug_toolbar/templates/debug_toolbar/redirect.html +++ b/debug_toolbar/templates/debug_toolbar/redirect.html @@ -3,7 +3,7 @@ Django Debug Toolbar Redirects Panel: {{ status_line }} - +

{{ status_line }}

diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index afb7affac..7f7f6c76b 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -65,6 +65,20 @@ def enabled_panels(self): """ return [panel for panel in self._panels.values() if panel.enabled] + @property + def csp_nonce(self): + """ + Look up the Content Security Policy nonce if there is one. + + This is built specifically for django-csp, which may not always + have a nonce associated with the request. Use the private attribute + because the lazy object wrapped value can generate a nonce by + accessing it. This isn't ideal when the toolbar is injecting context + into the response because it may set a nonce that is not used with + other assets. + """ + return getattr(self.request, "_csp_nonce", None) + def get_panel_by_id(self, panel_id): """ Get the panel with the given id, which is the class name by default. diff --git a/docs/changes.rst b/docs/changes.rst index f982350c4..89ee7dddc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -11,6 +11,7 @@ Pending or ``async_to_sync`` to allow sync/async compatibility. * Make ``require_toolbar`` decorator compatible to async views. * Added link to contributing documentation in ``CONTRIBUTING.md``. +* Rely on django-csp's private attribute for nonce, ``request._csp_nonce``. 5.0.1 (2025-01-13) ------------------ diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index a84f958c1..c5d957920 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -13,6 +13,13 @@ from .base import IntegrationTestCase +MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE[:] +MIDDLEWARE_CSP_BEFORE.insert( + MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"), + "csp.middleware.CSPMiddleware", +) +MIDDLEWARE_CSP_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] + def get_namespaces(element: Element) -> dict[str, str]: """ @@ -63,70 +70,97 @@ def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser): msg = self._formatMessage(None, "\n".join(default_msg)) raise self.failureException(msg) - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] - ) def test_exists(self): """A `nonce` should exist when using the `CSPMiddleware`.""" - response = cast(HttpResponse, self.client.get(path="/regular/basic/")) - self.assertEqual(response.status_code, 200) - - html_root: Element = self.parser.parse(stream=response.content) - self._fail_on_invalid_html(content=response.content, parser=self.parser) - self.assertContains(response, "djDebug") - - namespaces = get_namespaces(element=html_root) - toolbar = list(DebugToolbar._store.values())[0] - nonce = str(toolbar.request.csp_nonce) - self._fail_if_missing( - root=html_root, path=".//link", namespaces=namespaces, nonce=nonce - ) - self._fail_if_missing( - root=html_root, path=".//script", namespaces=namespaces, nonce=nonce - ) + for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + with self.settings(MIDDLEWARE=middleware): + response = cast(HttpResponse, self.client.get(path="/csp_view/")) + self.assertEqual(response.status_code, 200) + + html_root: Element = self.parser.parse(stream=response.content) + self._fail_on_invalid_html(content=response.content, parser=self.parser) + self.assertContains(response, "djDebug") + + namespaces = get_namespaces(element=html_root) + toolbar = list(DebugToolbar._store.values())[-1] + nonce = str(toolbar.csp_nonce) + self._fail_if_missing( + root=html_root, path=".//link", namespaces=namespaces, nonce=nonce + ) + self._fail_if_missing( + root=html_root, path=".//script", namespaces=namespaces, nonce=nonce + ) + + def test_does_not_exist_nonce_wasnt_used(self): + """ + A `nonce` should not exist even when using the `CSPMiddleware` + if the view didn't access the request.csp_nonce attribute. + """ + for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + with self.settings(MIDDLEWARE=middleware): + response = cast(HttpResponse, self.client.get(path="/regular/basic/")) + self.assertEqual(response.status_code, 200) + + html_root: Element = self.parser.parse(stream=response.content) + self._fail_on_invalid_html(content=response.content, parser=self.parser) + self.assertContains(response, "djDebug") + + namespaces = get_namespaces(element=html_root) + self._fail_if_found( + root=html_root, path=".//link", namespaces=namespaces + ) + self._fail_if_found( + root=html_root, path=".//script", namespaces=namespaces + ) @override_settings( DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()}, - MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"], ) def test_redirects_exists(self): - response = cast(HttpResponse, self.client.get(path="/regular/basic/")) - self.assertEqual(response.status_code, 200) + for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + with self.settings(MIDDLEWARE=middleware): + response = cast(HttpResponse, self.client.get(path="/csp_view/")) + self.assertEqual(response.status_code, 200) + + html_root: Element = self.parser.parse(stream=response.content) + self._fail_on_invalid_html(content=response.content, parser=self.parser) + self.assertContains(response, "djDebug") + + namespaces = get_namespaces(element=html_root) + context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue] + nonce = str(context["toolbar"].csp_nonce) + self._fail_if_missing( + root=html_root, path=".//link", namespaces=namespaces, nonce=nonce + ) + self._fail_if_missing( + root=html_root, path=".//script", namespaces=namespaces, nonce=nonce + ) - html_root: Element = self.parser.parse(stream=response.content) - self._fail_on_invalid_html(content=response.content, parser=self.parser) - self.assertContains(response, "djDebug") - - namespaces = get_namespaces(element=html_root) - context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue] - nonce = str(context["toolbar"].request.csp_nonce) - self._fail_if_missing( - root=html_root, path=".//link", namespaces=namespaces, nonce=nonce - ) - self._fail_if_missing( - root=html_root, path=".//script", namespaces=namespaces, nonce=nonce - ) - - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] - ) def test_panel_content_nonce_exists(self): - response = cast(HttpResponse, self.client.get(path="/regular/basic/")) - self.assertEqual(response.status_code, 200) - - toolbar = list(DebugToolbar._store.values())[0] - panels_to_check = ["HistoryPanel", "TimerPanel"] - for panel in panels_to_check: - content = toolbar.get_panel_by_id(panel).content - html_root: Element = self.parser.parse(stream=content) - namespaces = get_namespaces(element=html_root) - nonce = str(toolbar.request.csp_nonce) - self._fail_if_missing( - root=html_root, path=".//link", namespaces=namespaces, nonce=nonce - ) - self._fail_if_missing( - root=html_root, path=".//script", namespaces=namespaces, nonce=nonce - ) + for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + with self.settings(MIDDLEWARE=middleware): + response = cast(HttpResponse, self.client.get(path="/csp_view/")) + self.assertEqual(response.status_code, 200) + + toolbar = list(DebugToolbar._store.values())[-1] + panels_to_check = ["HistoryPanel", "TimerPanel"] + for panel in panels_to_check: + content = toolbar.get_panel_by_id(panel).content + html_root: Element = self.parser.parse(stream=content) + namespaces = get_namespaces(element=html_root) + nonce = str(toolbar.csp_nonce) + self._fail_if_missing( + root=html_root, + path=".//link", + namespaces=namespaces, + nonce=nonce, + ) + self._fail_if_missing( + root=html_root, + path=".//script", + namespaces=namespaces, + nonce=nonce, + ) def test_missing(self): """A `nonce` should not exist when not using the `CSPMiddleware`.""" diff --git a/tests/urls.py b/tests/urls.py index 68c6e0354..124e55892 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -25,6 +25,7 @@ path("redirect/", views.redirect_view), path("ajax/", views.ajax_view), path("login_without_redirect/", LoginView.as_view(redirect_field_name=None)), + path("csp_view/", views.csp_view), path("admin/", admin.site.urls), path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/tests/views.py b/tests/views.py index e8528ff2e..b6e3252af 100644 --- a/tests/views.py +++ b/tests/views.py @@ -42,6 +42,11 @@ def regular_view(request, title): return render(request, "basic.html", {"title": title}) +def csp_view(request): + """Use request.csp_nonce to inject it into the headers""" + return render(request, "basic.html", {"title": f"CSP {request.csp_nonce}"}) + + def template_response_view(request, title): return TemplateResponse(request, "basic.html", {"title": title}) From c1bd360396a056650950bc2ecf7303b56852b0d8 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 26 Feb 2025 16:42:15 -0600 Subject: [PATCH 2/5] Add csp to our words list. --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 662e6df4f..668b324de 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -15,6 +15,7 @@ backends backported checkbox contrib +csp dicts django fallbacks From aa229c01b2867d761c3f99449845a4137ba04c75 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Thu, 27 Feb 2025 09:35:44 -0600 Subject: [PATCH 3/5] Switch from list comprehension copy to explict copy --- tests/test_csp_rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index c5d957920..144e65ba0 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -13,7 +13,7 @@ from .base import IntegrationTestCase -MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE[:] +MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE.copy() MIDDLEWARE_CSP_BEFORE.insert( MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"), "csp.middleware.CSPMiddleware", From 43ec028964428ace40be8aafa9971192d8a84edc Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Fri, 14 Mar 2025 17:09:22 -0500 Subject: [PATCH 4/5] Consolidate csp_nonce usages to a single property on the toolbar. (#2099) * Consolidate csp_nonce usages to a single property on the toolbar. * Unpin django-csp for tests. --- debug_toolbar/toolbar.py | 4 ++-- docs/changes.rst | 2 +- tox.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 7f7f6c76b..6fae494e0 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -74,10 +74,10 @@ def csp_nonce(self): have a nonce associated with the request. Use the private attribute because the lazy object wrapped value can generate a nonce by accessing it. This isn't ideal when the toolbar is injecting context - into the response because it may set a nonce that is not used with + into the response because it may set a nonce not used with other assets. """ - return getattr(self.request, "_csp_nonce", None) + return getattr(self.request, "csp_nonce", None) def get_panel_by_id(self, panel_id): """ diff --git a/docs/changes.rst b/docs/changes.rst index 89ee7dddc..b0f6cf3a4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -11,7 +11,7 @@ Pending or ``async_to_sync`` to allow sync/async compatibility. * Make ``require_toolbar`` decorator compatible to async views. * Added link to contributing documentation in ``CONTRIBUTING.md``. -* Rely on django-csp's private attribute for nonce, ``request._csp_nonce``. +* Create a CSP nonce property on the toolbar ``Toolbar().csp_nonce``. 5.0.1 (2025-01-13) ------------------ diff --git a/tox.ini b/tox.ini index 691ba2670..c8f4a6815 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ deps = pygments selenium>=4.8.0 sqlparse - django-csp<4 + django-csp passenv= CI COVERAGE_ARGS From 98678de4bd5b1e9d35c0699da4d999f0f0870bfb Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 17 Mar 2025 07:47:21 -0500 Subject: [PATCH 5/5] Update csp_nonce docstring --- debug_toolbar/toolbar.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 6fae494e0..04e5894c5 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -71,11 +71,7 @@ def csp_nonce(self): Look up the Content Security Policy nonce if there is one. This is built specifically for django-csp, which may not always - have a nonce associated with the request. Use the private attribute - because the lazy object wrapped value can generate a nonce by - accessing it. This isn't ideal when the toolbar is injecting context - into the response because it may set a nonce not used with - other assets. + have a nonce associated with the request. """ return getattr(self.request, "csp_nonce", None)