Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions debug_toolbar/_compat.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import django

try:
from django.contrib.auth.decorators import login_not_required
except ImportError:
Expand All @@ -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
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)
42 changes: 32 additions & 10 deletions tests/test_csp_rendering.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
from __future__ import annotations

import django
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 = 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):
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
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