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 %}
{% 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/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
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
index a84f958c1..144e65ba0 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.copy()
+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})