diff --git a/.gitignore b/.gitignore
index 988922d50..c89013a11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ geckodriver.log
coverage.xml
.direnv/
.envrc
+venv
diff --git a/debug_toolbar/panels/redirects.py b/debug_toolbar/panels/redirects.py
index 8894d1a18..349564edb 100644
--- a/debug_toolbar/panels/redirects.py
+++ b/debug_toolbar/panels/redirects.py
@@ -21,7 +21,11 @@ def process_request(self, request):
if redirect_to:
status_line = f"{response.status_code} {response.reason_phrase}"
cookies = response.cookies
- context = {"redirect_to": redirect_to, "status_line": status_line}
+ context = {
+ "redirect_to": redirect_to,
+ "status_line": status_line,
+ "toolbar": self.toolbar,
+ }
# Using SimpleTemplateResponse avoids running global context processors.
response = SimpleTemplateResponse(
"debug_toolbar/redirect.html", context
diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html
index 4867a834e..b0308be55 100644
--- a/debug_toolbar/templates/debug_toolbar/base.html
+++ b/debug_toolbar/templates/debug_toolbar/base.html
@@ -1,10 +1,10 @@
{% load i18n static %}
{% block css %}
-
-
+
+
{% endblock %}
{% block js %}
-
+
{% endblock %}
{% if toolbar.should_render_panels %}
- {% for script in panel.scripts %}{% endfor %}
+ {% for script in panel.scripts %}{% endfor %}
{{ panel.content }}
{% else %}
diff --git a/debug_toolbar/templates/debug_toolbar/redirect.html b/debug_toolbar/templates/debug_toolbar/redirect.html
index 96b97de2d..cb6b4a6ea 100644
--- a/debug_toolbar/templates/debug_toolbar/redirect.html
+++ b/debug_toolbar/templates/debug_toolbar/redirect.html
@@ -3,7 +3,7 @@
Django Debug Toolbar Redirects Panel: {{ status_line }}
-
+
{{ status_line }}
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index e1b5474de..35d789a53 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -4,9 +4,11 @@
import re
import uuid
-from collections import OrderedDict
from functools import lru_cache
+# Can be removed when python3.8 is dropped
+from typing import OrderedDict
+
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@@ -19,6 +21,7 @@
from django.utils.translation import get_language, override as lang_override
from debug_toolbar import APP_NAME, settings as dt_settings
+from debug_toolbar.panels import Panel
class DebugToolbar:
@@ -38,7 +41,7 @@ def __init__(self, request, get_response):
# Use OrderedDict for the _panels attribute so that items can be efficiently
# removed using FIFO order in the DebugToolbar.store() method. The .popitem()
# method of Python's built-in dict only supports LIFO removal.
- self._panels = OrderedDict()
+ self._panels = OrderedDict[str, Panel]()
while panels:
panel = panels.pop()
self._panels[panel.panel_id] = panel
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 03e436622..e66eba5c6 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -11,6 +11,7 @@ html5lib
selenium
tox
black
+django-csp # Used in tests/test_csp_rendering
# Integration support
diff --git a/tests/base.py b/tests/base.py
index 5cc432add..9d12c5219 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,8 +1,11 @@
+from typing import Optional
+
import html5lib
from asgiref.local import Local
from django.http import HttpResponse
from django.test import Client, RequestFactory, TestCase, TransactionTestCase
+from debug_toolbar.panels import Panel
from debug_toolbar.toolbar import DebugToolbar
@@ -32,6 +35,7 @@ def handle_toolbar_created(sender, toolbar=None, **kwargs):
class BaseMixin:
client_class = ToolbarTestClient
+ panel: Optional[Panel] = None
panel_id = None
def setUp(self):
diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py
new file mode 100644
index 000000000..5e355b15a
--- /dev/null
+++ b/tests/test_csp_rendering.py
@@ -0,0 +1,140 @@
+from typing import Dict, cast
+from xml.etree.ElementTree import Element
+
+from django.conf import settings
+from django.http.response import HttpResponse
+from django.test.utils import ContextList, override_settings
+from html5lib.constants import E
+from html5lib.html5parser import HTMLParser
+
+from debug_toolbar.toolbar import DebugToolbar
+
+from .base import IntegrationTestCase
+
+
+def get_namespaces(element: Element) -> Dict[str, str]:
+ """
+ Return the default `xmlns`. See
+ https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
+ """
+ if not element.tag.startswith("{"):
+ return {}
+ return {"": element.tag[1:].split("}", maxsplit=1)[0]}
+
+
+@override_settings(DEBUG=True)
+class CspRenderingTestCase(IntegrationTestCase):
+ """Testing if `csp-nonce` renders."""
+
+ def setUp(self):
+ super().setUp()
+ self.parser = HTMLParser()
+
+ def _fail_if_missing(
+ self, root: Element, path: str, namespaces: Dict[str, str], nonce: str
+ ):
+ """
+ Search elements, fail if a `nonce` attribute is missing on them.
+ """
+ elements = root.findall(path=path, namespaces=namespaces)
+ for item in elements:
+ if item.attrib.get("nonce") != nonce:
+ raise self.failureException(f"{item} has no nonce attribute.")
+
+ def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]):
+ """
+ Search elements, fail if a `nonce` attribute is found on them.
+ """
+ elements = root.findall(path=path, namespaces=namespaces)
+ for item in elements:
+ if "nonce" in item.attrib:
+ raise self.failureException(f"{item} has a nonce attribute.")
+
+ def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
+ """Fail if the passed HTML is invalid."""
+ if parser.errors:
+ default_msg = ["Content is invalid HTML:"]
+ lines = content.split(b"\n")
+ for position, error_code, data_vars in parser.errors:
+ default_msg.append(" %s" % E[error_code] % data_vars)
+ default_msg.append(" %r" % lines[position[0] - 1])
+ 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
+ )
+
+ @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)
+
+ 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
+ )
+
+ def test_missing(self):
+ """A `nonce` should not exist when not 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)
+ self._fail_if_found(root=html_root, path=".//link", namespaces=namespaces)
+ self._fail_if_found(root=html_root, path=".//script", namespaces=namespaces)
diff --git a/tox.ini b/tox.ini
index a0e72827a..160b33db7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,6 +21,7 @@ deps =
pygments
selenium>=4.8.0
sqlparse
+ django-csp
passenv=
CI
COVERAGE_ARGS