diff --git a/debug_toolbar/_compat.py b/debug_toolbar/_compat.py index 0e0ab8c1b..8480e22e3 100644 --- a/debug_toolbar/_compat.py +++ b/debug_toolbar/_compat.py @@ -1,3 +1,5 @@ +import django + try: from django.contrib.auth.decorators import login_not_required except ImportError: @@ -8,3 +10,11 @@ def login_not_required(view_func): """ view_func.login_required = False return view_func + + +if django.VERSION >= (6, 0): + from django.middleware.csp import get_nonce +else: + # For Django < 6.0, there is no native CSP support, hence no CSP nonces. + def get_nonce(request): + return None diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index ec0472790..0e22c8f06 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -27,6 +27,7 @@ from debug_toolbar.store import BaseStore, get_store from .panels import Panel +from .utils import get_csp_nonce logger = logging.getLogger(__name__) @@ -78,11 +79,8 @@ def enabled_panels(self) -> list[Panel]: def csp_nonce(self) -> str | None: """ 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. """ - return getattr(self.request, "csp_nonce", None) + return get_csp_nonce(self.request) def get_panel_by_id(self, panel_id: str) -> Panel: """ diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index f4b3eac38..6a46ed2c6 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -16,7 +16,7 @@ from django.utils.safestring import SafeString, mark_safe from django.views.debug import get_default_exception_reporter_filter -from debug_toolbar import _stubs as stubs, settings as dt_settings +from debug_toolbar import _compat as compat, _stubs as stubs, settings as dt_settings _local_data = Local() safe_filter = get_default_exception_reporter_filter() @@ -401,3 +401,17 @@ def is_processable_html_response(response): and content_encoding == "" and content_type in _HTML_TYPES ) + + +def get_csp_nonce(request) -> str | None: + """ + Retrieve the Content Security Policy nonce from a request if there is one. + + This supports both the django-csp and the built-in Django implementations. + """ + # django-csp uses request.csp_nonce + csp_nonce = getattr(request, "csp_nonce", None) + if csp_nonce is not None: + return csp_nonce + # Django's built-in CSP support uses get_nonce(request) + return compat.get_nonce(request) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index 5bbd27f3b..289be73f7 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -1,5 +1,6 @@ from __future__ import annotations +import django from django.conf import settings from django.test.utils import override_settings from html5lib.constants import E @@ -7,15 +8,35 @@ from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar +from debug_toolbar.utils import get_csp_nonce from .base import IntegrationTestCase -MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE.copy() -MIDDLEWARE_CSP_BEFORE.insert( - MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"), +MIDDLEWARE_CSP_LIB_BEFORE = settings.MIDDLEWARE.copy() +MIDDLEWARE_CSP_LIB_BEFORE.insert( + MIDDLEWARE_CSP_LIB_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"), "csp.middleware.CSPMiddleware", ) -MIDDLEWARE_CSP_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] +MIDDLEWARE_CSP_LIB_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] + +VALID_MIDDLEWARE_VARIATIONS = [MIDDLEWARE_CSP_LIB_BEFORE, MIDDLEWARE_CSP_LIB_LAST] + +django_has_builtin_csp_support = django.VERSION >= (6, 0) +if django_has_builtin_csp_support: + MIDDLEWARE_CSP_BUILTIN_BEFORE = settings.MIDDLEWARE.copy() + MIDDLEWARE_CSP_BUILTIN_BEFORE.insert( + MIDDLEWARE_CSP_BUILTIN_BEFORE.index( + "debug_toolbar.middleware.DebugToolbarMiddleware" + ), + "django.middleware.csp.ContentSecurityPolicyMiddleware", + ) + MIDDLEWARE_CSP_BUILTIN_LAST = settings.MIDDLEWARE + [ + "django.middleware.csp.ContentSecurityPolicyMiddleware" + ] + VALID_MIDDLEWARE_VARIATIONS += [ + MIDDLEWARE_CSP_BUILTIN_BEFORE, + MIDDLEWARE_CSP_BUILTIN_LAST, + ] def get_namespaces(element): @@ -67,7 +88,7 @@ def _fail_on_invalid_html(self, content, parser): def test_exists(self): """A `nonce` should exist when using the `CSPMiddleware`.""" - for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + for middleware in VALID_MIDDLEWARE_VARIATIONS: with self.settings(MIDDLEWARE=middleware): response = self.client.get(path="/csp_view/") self.assertEqual(response.status_code, 200) @@ -77,7 +98,8 @@ def test_exists(self): self.assertContains(response, "djDebug") namespaces = get_namespaces(element=html_root) - nonce = response.context["request"].csp_nonce + nonce = get_csp_nonce(response.context["request"]) + assert nonce is not None self._fail_if_missing( root=html_root, path=".//link", namespaces=namespaces, nonce=nonce ) @@ -88,9 +110,9 @@ def test_exists(self): 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. + if the view didn't access the request's CSP nonce. """ - for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + for middleware in VALID_MIDDLEWARE_VARIATIONS: with self.settings(MIDDLEWARE=middleware): response = self.client.get(path="/regular/basic/") self.assertEqual(response.status_code, 200) @@ -111,7 +133,7 @@ def test_does_not_exist_nonce_wasnt_used(self): DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()}, ) def test_redirects_exists(self): - for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + for middleware in VALID_MIDDLEWARE_VARIATIONS: with self.settings(MIDDLEWARE=middleware): response = self.client.get(path="/csp_view/") self.assertEqual(response.status_code, 200) @@ -132,7 +154,7 @@ def test_redirects_exists(self): def test_panel_content_nonce_exists(self): store = get_store() - for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]: + for middleware in VALID_MIDDLEWARE_VARIATIONS: with self.settings(MIDDLEWARE=middleware): response = self.client.get(path="/csp_view/") self.assertEqual(response.status_code, 200) diff --git a/tests/views.py b/tests/views.py index fa8f0cf22..5d3a51ffb 100644 --- a/tests/views.py +++ b/tests/views.py @@ -8,6 +8,7 @@ from django.template.response import TemplateResponse from django.views.decorators.cache import cache_page +from debug_toolbar.utils import get_csp_nonce from tests.models import PostgresJSON @@ -73,7 +74,8 @@ def regular_view(request, 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}"}) + nonce = get_csp_nonce(request) + return render(request, "basic.html", {"title": f"CSP {nonce}"}) def template_response_view(request, title):