Skip to content

Commit cbb479f

Browse files
Refactor on csp_nonce usage with django-csp (#2088)
* Consolidate csp_nonce usages to a single property on the toolbar. This refactors how the CSP nonce is fetched. It's now done as a toolbar property and wraps the attribute request.csp_nonce * Add csp to our words list. * Unpin django-csp for tests.
1 parent c557f24 commit cbb479f

File tree

10 files changed

+114
-61
lines changed

10 files changed

+114
-61
lines changed

debug_toolbar/templates/debug_toolbar/base.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{% load i18n static %}
22
{% block css %}
3-
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
4-
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
3+
<link{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
4+
<link{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
55
{% endblock %}
66
{% block js %}
7-
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
7+
<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
88
{% endblock %}
99
<div id="djDebug" class="djdt-hidden" dir="ltr"
1010
{% if not toolbar.should_render_panels %}

debug_toolbar/templates/debug_toolbar/includes/panel_content.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ <h3>{{ panel.title }}</h3>
88
</div>
99
<div class="djDebugPanelContent">
1010
{% if toolbar.should_render_panels %}
11-
{% for script in panel.scripts %}<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
11+
{% for script in panel.scripts %}<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
1212
<div class="djdt-scroll">{{ panel.content }}</div>
1313
{% else %}
1414
<div class="djdt-loader"></div>

debug_toolbar/templates/debug_toolbar/redirect.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<html lang="en">
44
<head>
55
<title>Django Debug Toolbar Redirects Panel: {{ status_line }}</title>
6-
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
6+
<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
77
</head>
88
<body>
99
<h1>{{ status_line }}</h1>

debug_toolbar/toolbar.py

+10
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ def enabled_panels(self):
6565
"""
6666
return [panel for panel in self._panels.values() if panel.enabled]
6767

68+
@property
69+
def csp_nonce(self):
70+
"""
71+
Look up the Content Security Policy nonce if there is one.
72+
73+
This is built specifically for django-csp, which may not always
74+
have a nonce associated with the request.
75+
"""
76+
return getattr(self.request, "csp_nonce", None)
77+
6878
def get_panel_by_id(self, panel_id):
6979
"""
7080
Get the panel with the given id, which is the class name by default.

docs/changes.rst

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Pending
1818
* Avoided a "forked" Promise chain in the rebound ``window.fetch`` function
1919
with missing exception handling.
2020
* Fixed the pygments code highlighting when using dark mode.
21+
* Fix for exception-unhandled "forked" Promise chain in rebound window.fetch
22+
* Create a CSP nonce property on the toolbar ``Toolbar().csp_nonce``.
2123

2224
5.0.1 (2025-01-13)
2325
------------------

docs/spelling_wordlist.txt

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ backported
1616
biome
1717
checkbox
1818
contrib
19+
csp
1920
dicts
2021
django
2122
fallbacks

tests/test_csp_rendering.py

+89-55
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313

1414
from .base import IntegrationTestCase
1515

16+
MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE.copy()
17+
MIDDLEWARE_CSP_BEFORE.insert(
18+
MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"),
19+
"csp.middleware.CSPMiddleware",
20+
)
21+
MIDDLEWARE_CSP_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
22+
1623

1724
def get_namespaces(element: Element) -> dict[str, str]:
1825
"""
@@ -63,70 +70,97 @@ def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
6370
msg = self._formatMessage(None, "\n".join(default_msg))
6471
raise self.failureException(msg)
6572

66-
@override_settings(
67-
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
68-
)
6973
def test_exists(self):
7074
"""A `nonce` should exist when using the `CSPMiddleware`."""
71-
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
72-
self.assertEqual(response.status_code, 200)
73-
74-
html_root: Element = self.parser.parse(stream=response.content)
75-
self._fail_on_invalid_html(content=response.content, parser=self.parser)
76-
self.assertContains(response, "djDebug")
77-
78-
namespaces = get_namespaces(element=html_root)
79-
toolbar = list(DebugToolbar._store.values())[0]
80-
nonce = str(toolbar.request.csp_nonce)
81-
self._fail_if_missing(
82-
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
83-
)
84-
self._fail_if_missing(
85-
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
86-
)
75+
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
76+
with self.settings(MIDDLEWARE=middleware):
77+
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
78+
self.assertEqual(response.status_code, 200)
79+
80+
html_root: Element = self.parser.parse(stream=response.content)
81+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
82+
self.assertContains(response, "djDebug")
83+
84+
namespaces = get_namespaces(element=html_root)
85+
toolbar = list(DebugToolbar._store.values())[-1]
86+
nonce = str(toolbar.csp_nonce)
87+
self._fail_if_missing(
88+
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
89+
)
90+
self._fail_if_missing(
91+
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
92+
)
93+
94+
def test_does_not_exist_nonce_wasnt_used(self):
95+
"""
96+
A `nonce` should not exist even when using the `CSPMiddleware`
97+
if the view didn't access the request.csp_nonce attribute.
98+
"""
99+
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
100+
with self.settings(MIDDLEWARE=middleware):
101+
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
102+
self.assertEqual(response.status_code, 200)
103+
104+
html_root: Element = self.parser.parse(stream=response.content)
105+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
106+
self.assertContains(response, "djDebug")
107+
108+
namespaces = get_namespaces(element=html_root)
109+
self._fail_if_found(
110+
root=html_root, path=".//link", namespaces=namespaces
111+
)
112+
self._fail_if_found(
113+
root=html_root, path=".//script", namespaces=namespaces
114+
)
87115

88116
@override_settings(
89117
DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()},
90-
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"],
91118
)
92119
def test_redirects_exists(self):
93-
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
94-
self.assertEqual(response.status_code, 200)
120+
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
121+
with self.settings(MIDDLEWARE=middleware):
122+
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
123+
self.assertEqual(response.status_code, 200)
124+
125+
html_root: Element = self.parser.parse(stream=response.content)
126+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
127+
self.assertContains(response, "djDebug")
128+
129+
namespaces = get_namespaces(element=html_root)
130+
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
131+
nonce = str(context["toolbar"].csp_nonce)
132+
self._fail_if_missing(
133+
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
134+
)
135+
self._fail_if_missing(
136+
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
137+
)
95138

96-
html_root: Element = self.parser.parse(stream=response.content)
97-
self._fail_on_invalid_html(content=response.content, parser=self.parser)
98-
self.assertContains(response, "djDebug")
99-
100-
namespaces = get_namespaces(element=html_root)
101-
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
102-
nonce = str(context["toolbar"].request.csp_nonce)
103-
self._fail_if_missing(
104-
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
105-
)
106-
self._fail_if_missing(
107-
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
108-
)
109-
110-
@override_settings(
111-
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
112-
)
113139
def test_panel_content_nonce_exists(self):
114-
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
115-
self.assertEqual(response.status_code, 200)
116-
117-
toolbar = list(DebugToolbar._store.values())[0]
118-
panels_to_check = ["HistoryPanel", "TimerPanel"]
119-
for panel in panels_to_check:
120-
content = toolbar.get_panel_by_id(panel).content
121-
html_root: Element = self.parser.parse(stream=content)
122-
namespaces = get_namespaces(element=html_root)
123-
nonce = str(toolbar.request.csp_nonce)
124-
self._fail_if_missing(
125-
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
126-
)
127-
self._fail_if_missing(
128-
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
129-
)
140+
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
141+
with self.settings(MIDDLEWARE=middleware):
142+
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
143+
self.assertEqual(response.status_code, 200)
144+
145+
toolbar = list(DebugToolbar._store.values())[-1]
146+
panels_to_check = ["HistoryPanel", "TimerPanel"]
147+
for panel in panels_to_check:
148+
content = toolbar.get_panel_by_id(panel).content
149+
html_root: Element = self.parser.parse(stream=content)
150+
namespaces = get_namespaces(element=html_root)
151+
nonce = str(toolbar.csp_nonce)
152+
self._fail_if_missing(
153+
root=html_root,
154+
path=".//link",
155+
namespaces=namespaces,
156+
nonce=nonce,
157+
)
158+
self._fail_if_missing(
159+
root=html_root,
160+
path=".//script",
161+
namespaces=namespaces,
162+
nonce=nonce,
163+
)
130164

131165
def test_missing(self):
132166
"""A `nonce` should not exist when not using the `CSPMiddleware`."""

tests/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
path("redirect/", views.redirect_view),
2626
path("ajax/", views.ajax_view),
2727
path("login_without_redirect/", LoginView.as_view(redirect_field_name=None)),
28+
path("csp_view/", views.csp_view),
2829
path("admin/", admin.site.urls),
2930
path("__debug__/", include("debug_toolbar.urls")),
3031
]

tests/views.py

+5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def regular_view(request, title):
4242
return render(request, "basic.html", {"title": title})
4343

4444

45+
def csp_view(request):
46+
"""Use request.csp_nonce to inject it into the headers"""
47+
return render(request, "basic.html", {"title": f"CSP {request.csp_nonce}"})
48+
49+
4550
def template_response_view(request, title):
4651
return TemplateResponse(request, "basic.html", {"title": title})
4752

tox.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ deps =
2525
pygments
2626
selenium>=4.8.0
2727
sqlparse
28-
django-csp<4
28+
django-csp
2929
passenv=
3030
CI
3131
COVERAGE_ARGS

0 commit comments

Comments
 (0)