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 %}
{{ panel.title }}
{% 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