Skip to content

Commit

Permalink
Rely on django-csp's private attribute for nonce
Browse files Browse the repository at this point in the history
This refactors how the CSP nonce is fetched. It's now done as
a toolbar property and wraps the private attribute request._csp_nonce

This avoids the toolbar from generating a nonce that gets injected
into the CSP header when the view doesn't expect it to. It also
supports using a nonce that is generated from any other point
while processing the request, including other middleware.
  • Loading branch information
tim-schilling committed Feb 26, 2025
1 parent b389f85 commit 89b51bf
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 60 deletions.
6 changes: 3 additions & 3 deletions debug_toolbar/templates/debug_toolbar/base.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{% load i18n static %}
{% block css %}
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
<link{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
<link{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
{% endblock %}
{% block js %}
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
{% endblock %}
<div id="djDebug" class="djdt-hidden" dir="ltr"
{% if not toolbar.should_render_panels %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h3>{{ panel.title }}</h3>
</div>
<div class="djDebugPanelContent">
{% if toolbar.should_render_panels %}
{% for script in panel.scripts %}<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
{% for script in panel.scripts %}<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
<div class="djdt-scroll">{{ panel.content }}</div>
{% else %}
<div class="djdt-loader"></div>
Expand Down
2 changes: 1 addition & 1 deletion debug_toolbar/templates/debug_toolbar/redirect.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<html lang="en">
<head>
<title>Django Debug Toolbar Redirects Panel: {{ status_line }}</title>
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
<script{% if toolbar.csp_nonce %} nonce="{{ toolbar.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
</head>
<body>
<h1>{{ status_line }}</h1>
Expand Down
14 changes: 14 additions & 0 deletions debug_toolbar/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ def enabled_panels(self):
"""
return [panel for panel in self._panels.values() if panel.enabled]

@property
def csp_nonce(self):
"""
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. Use the private attribute
because the lazy object wrapped value can generate a nonce by
accessing it. This isn't ideal when the toolbar is injecting context
into the response because it may set a nonce that is not used with
other assets.
"""
return getattr(self.request, "_csp_nonce", None)

def get_panel_by_id(self, panel_id):
"""
Get the panel with the given id, which is the class name by default.
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Pending
or ``async_to_sync`` to allow sync/async compatibility.
* Make ``require_toolbar`` decorator compatible to async views.
* Added link to contributing documentation in ``CONTRIBUTING.md``.
* Rely on django-csp's private attribute for nonce, ``request._csp_nonce``.

5.0.1 (2025-01-13)
------------------
Expand Down
144 changes: 89 additions & 55 deletions tests/test_csp_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@

from .base import IntegrationTestCase

MIDDLEWARE_CSP_BEFORE = settings.MIDDLEWARE[:]
MIDDLEWARE_CSP_BEFORE.insert(
MIDDLEWARE_CSP_BEFORE.index("debug_toolbar.middleware.DebugToolbarMiddleware"),
"csp.middleware.CSPMiddleware",
)
MIDDLEWARE_CSP_LAST = settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]


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

@override_settings(
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
)
def test_exists(self):
"""A `nonce` should exist when using the `CSPMiddleware`."""
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
self.assertEqual(response.status_code, 200)

html_root: Element = self.parser.parse(stream=response.content)
self._fail_on_invalid_html(content=response.content, parser=self.parser)
self.assertContains(response, "djDebug")

namespaces = get_namespaces(element=html_root)
toolbar = list(DebugToolbar._store.values())[0]
nonce = str(toolbar.request.csp_nonce)
self._fail_if_missing(
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
)
self._fail_if_missing(
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
)
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
with self.settings(MIDDLEWARE=middleware):
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
self.assertEqual(response.status_code, 200)

html_root: Element = self.parser.parse(stream=response.content)
self._fail_on_invalid_html(content=response.content, parser=self.parser)
self.assertContains(response, "djDebug")

namespaces = get_namespaces(element=html_root)
toolbar = list(DebugToolbar._store.values())[-1]
nonce = str(toolbar.csp_nonce)
self._fail_if_missing(
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
)
self._fail_if_missing(
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
)

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.
"""
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
with self.settings(MIDDLEWARE=middleware):
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
self.assertEqual(response.status_code, 200)

html_root: Element = self.parser.parse(stream=response.content)
self._fail_on_invalid_html(content=response.content, parser=self.parser)
self.assertContains(response, "djDebug")

namespaces = get_namespaces(element=html_root)
self._fail_if_found(
root=html_root, path=".//link", namespaces=namespaces
)
self._fail_if_found(
root=html_root, path=".//script", namespaces=namespaces
)

@override_settings(
DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()},
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"],
)
def test_redirects_exists(self):
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
self.assertEqual(response.status_code, 200)
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
with self.settings(MIDDLEWARE=middleware):
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
self.assertEqual(response.status_code, 200)

html_root: Element = self.parser.parse(stream=response.content)
self._fail_on_invalid_html(content=response.content, parser=self.parser)
self.assertContains(response, "djDebug")

namespaces = get_namespaces(element=html_root)
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
nonce = str(context["toolbar"].csp_nonce)
self._fail_if_missing(
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
)
self._fail_if_missing(
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
)

html_root: Element = self.parser.parse(stream=response.content)
self._fail_on_invalid_html(content=response.content, parser=self.parser)
self.assertContains(response, "djDebug")

namespaces = get_namespaces(element=html_root)
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
nonce = str(context["toolbar"].request.csp_nonce)
self._fail_if_missing(
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
)
self._fail_if_missing(
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
)

@override_settings(
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
)
def test_panel_content_nonce_exists(self):
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
self.assertEqual(response.status_code, 200)

toolbar = list(DebugToolbar._store.values())[0]
panels_to_check = ["HistoryPanel", "TimerPanel"]
for panel in panels_to_check:
content = toolbar.get_panel_by_id(panel).content
html_root: Element = self.parser.parse(stream=content)
namespaces = get_namespaces(element=html_root)
nonce = str(toolbar.request.csp_nonce)
self._fail_if_missing(
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
)
self._fail_if_missing(
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
)
for middleware in [MIDDLEWARE_CSP_BEFORE, MIDDLEWARE_CSP_LAST]:
with self.settings(MIDDLEWARE=middleware):
response = cast(HttpResponse, self.client.get(path="/csp_view/"))
self.assertEqual(response.status_code, 200)

toolbar = list(DebugToolbar._store.values())[-1]
panels_to_check = ["HistoryPanel", "TimerPanel"]
for panel in panels_to_check:
content = toolbar.get_panel_by_id(panel).content
html_root: Element = self.parser.parse(stream=content)
namespaces = get_namespaces(element=html_root)
nonce = str(toolbar.csp_nonce)
self._fail_if_missing(
root=html_root,
path=".//link",
namespaces=namespaces,
nonce=nonce,
)
self._fail_if_missing(
root=html_root,
path=".//script",
namespaces=namespaces,
nonce=nonce,
)

def test_missing(self):
"""A `nonce` should not exist when not using the `CSPMiddleware`."""
Expand Down
1 change: 1 addition & 0 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
path("redirect/", views.redirect_view),
path("ajax/", views.ajax_view),
path("login_without_redirect/", LoginView.as_view(redirect_field_name=None)),
path("csp_view/", views.csp_view),
path("admin/", admin.site.urls),
path("__debug__/", include("debug_toolbar.urls")),
]
5 changes: 5 additions & 0 deletions tests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ def regular_view(request, title):
return render(request, "basic.html", {"title": 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}"})


def template_response_view(request, title):
return TemplateResponse(request, "basic.html", {"title": title})

Expand Down

0 comments on commit 89b51bf

Please sign in to comment.