Skip to content

Commit 7382193

Browse files
authored
Add Django built-in CSP nonce support (#2267)
Django 6.0 added built-in support for Content Security Policies. Part of this support is associating a randomly generated nonce to each request that can then be attached to <script> and <style> tags. django-debug-toolbar already has support for CSP nonces generated by the django-csp third-party lib but the nonces generated by django-csp and Django are accessed differently. This commit adds support for the CSP nonces generated by the built-in Django implementation.
1 parent 417b361 commit 7382193

File tree

5 files changed

+62
-16
lines changed

5 files changed

+62
-16
lines changed

debug_toolbar/_compat.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import django
2+
13
try:
24
from django.contrib.auth.decorators import login_not_required
35
except ImportError:
@@ -8,3 +10,11 @@ def login_not_required(view_func):
810
"""
911
view_func.login_required = False
1012
return view_func
13+
14+
15+
if django.VERSION >= (6, 0):
16+
from django.middleware.csp import get_nonce
17+
else:
18+
# For Django < 6.0, there is no native CSP support, hence no CSP nonces.
19+
def get_nonce(request):
20+
return None

debug_toolbar/toolbar.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from debug_toolbar.store import BaseStore, get_store
2828

2929
from .panels import Panel
30+
from .utils import get_csp_nonce
3031

3132
logger = logging.getLogger(__name__)
3233

@@ -78,11 +79,8 @@ def enabled_panels(self) -> list[Panel]:
7879
def csp_nonce(self) -> str | None:
7980
"""
8081
Look up the Content Security Policy nonce if there is one.
81-
82-
This is built specifically for django-csp, which may not always
83-
have a nonce associated with the request.
8482
"""
85-
return getattr(self.request, "csp_nonce", None)
83+
return get_csp_nonce(self.request)
8684

8785
def get_panel_by_id(self, panel_id: str) -> Panel:
8886
"""

debug_toolbar/utils.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.utils.safestring import SafeString, mark_safe
1717
from django.views.debug import get_default_exception_reporter_filter
1818

19-
from debug_toolbar import _stubs as stubs, settings as dt_settings
19+
from debug_toolbar import _compat as compat, _stubs as stubs, settings as dt_settings
2020

2121
_local_data = Local()
2222
safe_filter = get_default_exception_reporter_filter()
@@ -401,3 +401,17 @@ def is_processable_html_response(response):
401401
and content_encoding == ""
402402
and content_type in _HTML_TYPES
403403
)
404+
405+
406+
def get_csp_nonce(request) -> str | None:
407+
"""
408+
Retrieve the Content Security Policy nonce from a request if there is one.
409+
410+
This supports both the django-csp and the built-in Django implementations.
411+
"""
412+
# django-csp uses request.csp_nonce
413+
csp_nonce = getattr(request, "csp_nonce", None)
414+
if csp_nonce is not None:
415+
return csp_nonce
416+
# Django's built-in CSP support uses get_nonce(request)
417+
return compat.get_nonce(request)

tests/test_csp_rendering.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
11
from __future__ import annotations
22

3+
import django
34
from django.conf import settings
45
from django.test.utils import override_settings
56
from html5lib.constants import E
67
from html5lib.html5parser import HTMLParser
78

89
from debug_toolbar.store import get_store
910
from debug_toolbar.toolbar import DebugToolbar
11+
from debug_toolbar.utils import get_csp_nonce
1012

1113
from .base import IntegrationTestCase
1214

13-
MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE.copy()
14-
MIDDLEWARE_CSP_BEFORE.insert(
15-
MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"),
15+
MIDDLEWARE_CSP_LIB_BEFORE = settings.MIDDLEWARE.copy()
16+
MIDDLEWARE_CSP_LIB_BEFORE.insert(
17+
MIDDLEWARE_CSP_LIB_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"),
1618
"csp.middleware.CSPMiddleware",
1719
)
18-
MIDDLEWARE_CSP_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
20+
MIDDLEWARE_CSP_LIB_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
21+
22+
VALID_MIDDLEWARE_VARIATIONS = [MIDDLEWARE_CSP_LIB_BEFORE, MIDDLEWARE_CSP_LIB_LAST]
23+
24+
django_has_builtin_csp_support = django.VERSION >= (6, 0)
25+
if django_has_builtin_csp_support:
26+
MIDDLEWARE_CSP_BUILTIN_BEFORE = settings.MIDDLEWARE.copy()
27+
MIDDLEWARE_CSP_BUILTIN_BEFORE.insert(
28+
MIDDLEWARE_CSP_BUILTIN_BEFORE.index(
29+
"debug_toolbar.middleware.DebugToolbarMiddleware"
30+
),
31+
"django.middleware.csp.ContentSecurityPolicyMiddleware",
32+
)
33+
MIDDLEWARE_CSP_BUILTIN_LAST = settings.MIDDLEWARE + [
34+
"django.middleware.csp.ContentSecurityPolicyMiddleware"
35+
]
36+
VALID_MIDDLEWARE_VARIATIONS += [
37+
MIDDLEWARE_CSP_BUILTIN_BEFORE,
38+
MIDDLEWARE_CSP_BUILTIN_LAST,
39+
]
1940

2041

2142
def get_namespaces(element):
@@ -67,7 +88,7 @@ def _fail_on_invalid_html(self, content, parser):
6788

6889
def test_exists(self):
6990
"""A `nonce` should exist when using the `CSPMiddleware`."""
70-
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
91+
for middleware in VALID_MIDDLEWARE_VARIATIONS:
7192
with self.settings(MIDDLEWARE=middleware):
7293
response = self.client.get(path="/csp_view/")
7394
self.assertEqual(response.status_code, 200)
@@ -77,7 +98,8 @@ def test_exists(self):
7798
self.assertContains(response, "djDebug")
7899

79100
namespaces = get_namespaces(element=html_root)
80-
nonce = response.context["request"].csp_nonce
101+
nonce = get_csp_nonce(response.context["request"])
102+
assert nonce is not None
81103
self._fail_if_missing(
82104
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
83105
)
@@ -88,9 +110,9 @@ def test_exists(self):
88110
def test_does_not_exist_nonce_wasnt_used(self):
89111
"""
90112
A `nonce` should not exist even when using the `CSPMiddleware`
91-
if the view didn't access the request.csp_nonce attribute.
113+
if the view didn't access the request's CSP nonce.
92114
"""
93-
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
115+
for middleware in VALID_MIDDLEWARE_VARIATIONS:
94116
with self.settings(MIDDLEWARE=middleware):
95117
response = self.client.get(path="/regular/basic/")
96118
self.assertEqual(response.status_code, 200)
@@ -111,7 +133,7 @@ def test_does_not_exist_nonce_wasnt_used(self):
111133
DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()},
112134
)
113135
def test_redirects_exists(self):
114-
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
136+
for middleware in VALID_MIDDLEWARE_VARIATIONS:
115137
with self.settings(MIDDLEWARE=middleware):
116138
response = self.client.get(path="/csp_view/")
117139
self.assertEqual(response.status_code, 200)
@@ -132,7 +154,7 @@ def test_redirects_exists(self):
132154

133155
def test_panel_content_nonce_exists(self):
134156
store = get_store()
135-
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
157+
for middleware in VALID_MIDDLEWARE_VARIATIONS:
136158
with self.settings(MIDDLEWARE=middleware):
137159
response = self.client.get(path="/csp_view/")
138160
self.assertEqual(response.status_code, 200)

tests/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.template.response import TemplateResponse
99
from django.views.decorators.cache import cache_page
1010

11+
from debug_toolbar.utils import get_csp_nonce
1112
from tests.models import PostgresJSON
1213

1314

@@ -73,7 +74,8 @@ def regular_view(request, title):
7374

7475
def csp_view(request):
7576
"""Use request.csp_nonce to inject it into the headers"""
76-
return render(request, "basic.html", {"title": f"CSP {request.csp_nonce}"})
77+
nonce = get_csp_nonce(request)
78+
return render(request, "basic.html", {"title": f"CSP {nonce}"})
7779

7880

7981
def template_response_view(request, title):

0 commit comments

Comments
 (0)