Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions debug_toolbar/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ def login_not_required(view_func):
"""
view_func.login_required = False
return view_func


try:
from django.middleware.csp import get_nonce
except ImportError:
# For Django < 6.0, there is no native CSP support, hence no CSP nonces.
def get_nonce(request):
return None
6 changes: 2 additions & 4 deletions debug_toolbar/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
"""
Expand Down
16 changes: 15 additions & 1 deletion debug_toolbar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
43 changes: 33 additions & 10 deletions tests/test_csp_rendering.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
from __future__ import annotations

from importlib.util import find_spec

from django.conf import settings
from django.test.utils import override_settings
from html5lib.constants import E
from html5lib.html5parser import HTMLParser

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 = bool(find_spec("django.middleware.csp"))
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):
Expand Down Expand Up @@ -67,7 +89,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)
Expand All @@ -77,7 +99,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
)
Expand All @@ -88,9 +111,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)
Expand All @@ -111,7 +134,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)
Expand All @@ -132,7 +155,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)
Expand Down
4 changes: 3 additions & 1 deletion tests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
Loading