From fbbfca9a59398fa2790b5c1718c8011c9e758708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Tue, 30 Jul 2024 23:14:54 +0200 Subject: [PATCH 1/8] Quick hack for including csp_nonces from requests into script tags --- debug_toolbar/templates/debug_toolbar/base.html | 2 +- .../templates/debug_toolbar/includes/panel_content.html | 2 +- debug_toolbar/templates/debug_toolbar/redirect.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index 4867a834e..f5cef599a 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -4,7 +4,7 @@ {% 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 }}

From ba4ae5064a0cbed51f22c8fce6be3b45b7867854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Thu, 1 Aug 2024 19:54:02 +0200 Subject: [PATCH 2/8] Add rendering tests & nonces on link/style tags --- .gitignore | 1 + .../templates/debug_toolbar/base.html | 4 +- debug_toolbar/toolbar.py | 3 +- tests/base.py | 4 + tests/panels/test_template.py | 3 +- tests/test_csp_rendering.py | 103 ++++++++++++++++++ tests/test_integration.py | 1 - tox.ini | 1 + 8 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/test_csp_rendering.py 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/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index f5cef599a..b0308be55 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -1,7 +1,7 @@ {% load i18n static %} {% block css %} - - + + {% endblock %} {% block js %} diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index e1b5474de..a1f347d58 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -19,6 +19,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 +39,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/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/panels/test_template.py b/tests/panels/test_template.py index 636e88a23..bce8f026b 100644 --- a/tests/panels/test_template.py +++ b/tests/panels/test_template.py @@ -98,7 +98,8 @@ def test_custom_context_processor(self): ) def test_disabled(self): - config = {"DISABLE_PANELS": {"debug_toolbar.panels.templates.TemplatesPanel"}} + config = {"DISABLE_PANELS": { + "debug_toolbar.panels.templates.TemplatesPanel"}} self.assertTrue(self.panel.enabled) with self.settings(DEBUG_TOOLBAR_CONFIG=config): self.assertFalse(self.panel.enabled) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py new file mode 100644 index 000000000..d5d5d1016 --- /dev/null +++ b/tests/test_csp_rendering.py @@ -0,0 +1,103 @@ +from typing import Dict +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 .base import BaseTestCase + + +def _get_ns(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 dict() + return {'': element.tag[1:].split('}', maxsplit=1)[0]} + + +class CspRenderingTestCase(BaseTestCase): + 'Testing if `csp-nonce` renders.' + panel_id = "StaticFilesPanel" + + # def setUp(self): + # self.factory = RequestFactory() + # self.async_factory = AsyncRequestFactory() + + 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 no 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, errorcode, datavars in parser.errors: + default_msg.append(' %s' % E[errorcode] % datavars) + default_msg.append(' %r' % lines[position[0] - 1]) + msg = self._formatMessage(None, '\n'.join(default_msg)) + raise self.failureException(msg) + + @override_settings( + DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + [ + 'csp.middleware.CSPMiddleware' + ]) + def test_exists(self): + 'A `nonce` should exists when using the `CSPMiddleware`.' + response = self.client.get(path='/regular/basic/') + if not isinstance(response, HttpResponse): + raise self.failureException(f'{response!r} is not a HttpResponse') + self.assertEqual(response.status_code, 200) + parser = HTMLParser() + el_htmlroot: Element = parser.parse(stream=response.content) + self._fail_on_invalid_html(content=response.content, parser=parser) + self.assertContains(response, 'djDebug') + namespaces = _get_ns(element=el_htmlroot) + context: ContextList = \ + response.context # pyright: ignore[reportAttributeAccessIssue] + nonce = str(context['toolbar'].request.csp_nonce) + self._fail_if_missing( + root=el_htmlroot, path='.//link', namespaces=namespaces, + nonce=nonce) + self._fail_if_missing( + root=el_htmlroot, path='.//script', namespaces=namespaces, + nonce=nonce) + + @override_settings(DEBUG=True) + def test_missing(self): + 'A `nonce` should not exist when not using the `CSPMiddleware`.' + response = self.client.get(path='/regular/basic/') + if not isinstance(response, HttpResponse): + raise self.failureException(f'{response!r} is not a HttpResponse') + self.assertEqual(response.status_code, 200) + parser = HTMLParser() + el_htmlroot: Element = parser.parse(stream=response.content) + self._fail_on_invalid_html(content=response.content, parser=parser) + self.assertContains(response, 'djDebug') + namespaces = _get_ns(element=el_htmlroot) + self._fail_if_found( + root=el_htmlroot, path='.//link', namespaces=namespaces) + self._fail_if_found( + root=el_htmlroot, path='.//script', namespaces=namespaces) diff --git a/tests/test_integration.py b/tests/test_integration.py index df276d90c..1d94a5056 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -53,7 +53,6 @@ def title(self): def content(self): raise Exception - @override_settings(DEBUG=True) class DebugToolbarTestCase(BaseTestCase): def test_show_toolbar(self): 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 From 64041b66e214f7c68514279fc3569689b639d643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Thu, 1 Aug 2024 19:59:08 +0200 Subject: [PATCH 3/8] Fixing for py38 --- debug_toolbar/toolbar.py | 8 +++++--- tests/test_csp_rendering.py | 8 ++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index a1f347d58..d444abc87 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -4,8 +4,8 @@ import re import uuid -from collections import OrderedDict from functools import lru_cache +from typing import OrderedDict from django.apps import apps from django.conf import settings @@ -16,9 +16,11 @@ from django.urls import include, path, re_path, resolve from django.urls.exceptions import Resolver404 from django.utils.module_loading import import_string -from django.utils.translation import get_language, override as lang_override +from django.utils.translation import get_language +from django.utils.translation import override as lang_override -from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar import APP_NAME +from debug_toolbar import settings as dt_settings from debug_toolbar.panels import Panel diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index d5d5d1016..a46c8bf49 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -10,13 +10,13 @@ from .base import BaseTestCase -def _get_ns(element: Element) -> dict[str, str]: +def _get_ns(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 dict() + return {} return {'': element.tag[1:].split('}', maxsplit=1)[0]} @@ -24,10 +24,6 @@ class CspRenderingTestCase(BaseTestCase): 'Testing if `csp-nonce` renders.' panel_id = "StaticFilesPanel" - # def setUp(self): - # self.factory = RequestFactory() - # self.async_factory = AsyncRequestFactory() - def _fail_if_missing( self, root: Element, path: str, namespaces: Dict[str, str], nonce: str): From 260d2fbfff027865e6e430181c3baec7ea904f17 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Aug 2024 18:25:06 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- debug_toolbar/toolbar.py | 6 +-- tests/panels/test_template.py | 3 +- tests/test_csp_rendering.py | 74 +++++++++++++++++------------------ tests/test_integration.py | 1 + 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index d444abc87..b2541dfa0 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -16,11 +16,9 @@ from django.urls import include, path, re_path, resolve from django.urls.exceptions import Resolver404 from django.utils.module_loading import import_string -from django.utils.translation import get_language -from django.utils.translation import override as lang_override +from django.utils.translation import get_language, override as lang_override -from debug_toolbar import APP_NAME -from debug_toolbar import settings as dt_settings +from debug_toolbar import APP_NAME, settings as dt_settings from debug_toolbar.panels import Panel diff --git a/tests/panels/test_template.py b/tests/panels/test_template.py index bce8f026b..636e88a23 100644 --- a/tests/panels/test_template.py +++ b/tests/panels/test_template.py @@ -98,8 +98,7 @@ def test_custom_context_processor(self): ) def test_disabled(self): - config = {"DISABLE_PANELS": { - "debug_toolbar.panels.templates.TemplatesPanel"}} + config = {"DISABLE_PANELS": {"debug_toolbar.panels.templates.TemplatesPanel"}} self.assertTrue(self.panel.enabled) with self.settings(DEBUG_TOOLBAR_CONFIG=config): self.assertFalse(self.panel.enabled) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index a46c8bf49..d20c2bb07 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -15,85 +15,81 @@ def _get_ns(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('{'): + if not element.tag.startswith("{"): return {} - return {'': element.tag[1:].split('}', maxsplit=1)[0]} + return {"": element.tag[1:].split("}", maxsplit=1)[0]} class CspRenderingTestCase(BaseTestCase): - 'Testing if `csp-nonce` renders.' + "Testing if `csp-nonce` renders." + panel_id = "StaticFilesPanel" def _fail_if_missing( - self, root: Element, path: str, namespaces: Dict[str, str], - nonce: str): + 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.') + 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]): + 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 no nonce attribute.') + if "nonce" in item.attrib: + raise self.failureException(f"{item} has no nonce attribute.") def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser): - 'Fail if the passed HTML is invalid.' + "Fail if the passed HTML is invalid." if parser.errors: - default_msg = ['Content is invalid HTML:'] - lines = content.split(b'\n') + default_msg = ["Content is invalid HTML:"] + lines = content.split(b"\n") for position, errorcode, datavars in parser.errors: - default_msg.append(' %s' % E[errorcode] % datavars) - default_msg.append(' %r' % lines[position[0] - 1]) - msg = self._formatMessage(None, '\n'.join(default_msg)) + default_msg.append(" %s" % E[errorcode] % datavars) + default_msg.append(" %r" % lines[position[0] - 1]) + msg = self._formatMessage(None, "\n".join(default_msg)) raise self.failureException(msg) @override_settings( - DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + [ - 'csp.middleware.CSPMiddleware' - ]) + DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] + ) def test_exists(self): - 'A `nonce` should exists when using the `CSPMiddleware`.' - response = self.client.get(path='/regular/basic/') + "A `nonce` should exists when using the `CSPMiddleware`." + response = self.client.get(path="/regular/basic/") if not isinstance(response, HttpResponse): - raise self.failureException(f'{response!r} is not a HttpResponse') + raise self.failureException(f"{response!r} is not a HttpResponse") self.assertEqual(response.status_code, 200) parser = HTMLParser() el_htmlroot: Element = parser.parse(stream=response.content) self._fail_on_invalid_html(content=response.content, parser=parser) - self.assertContains(response, 'djDebug') + self.assertContains(response, "djDebug") namespaces = _get_ns(element=el_htmlroot) - context: ContextList = \ - response.context # pyright: ignore[reportAttributeAccessIssue] - nonce = str(context['toolbar'].request.csp_nonce) + context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue] + nonce = str(context["toolbar"].request.csp_nonce) self._fail_if_missing( - root=el_htmlroot, path='.//link', namespaces=namespaces, - nonce=nonce) + root=el_htmlroot, path=".//link", namespaces=namespaces, nonce=nonce + ) self._fail_if_missing( - root=el_htmlroot, path='.//script', namespaces=namespaces, - nonce=nonce) + root=el_htmlroot, path=".//script", namespaces=namespaces, nonce=nonce + ) @override_settings(DEBUG=True) def test_missing(self): - 'A `nonce` should not exist when not using the `CSPMiddleware`.' - response = self.client.get(path='/regular/basic/') + "A `nonce` should not exist when not using the `CSPMiddleware`." + response = self.client.get(path="/regular/basic/") if not isinstance(response, HttpResponse): - raise self.failureException(f'{response!r} is not a HttpResponse') + raise self.failureException(f"{response!r} is not a HttpResponse") self.assertEqual(response.status_code, 200) parser = HTMLParser() el_htmlroot: Element = parser.parse(stream=response.content) self._fail_on_invalid_html(content=response.content, parser=parser) - self.assertContains(response, 'djDebug') + self.assertContains(response, "djDebug") namespaces = _get_ns(element=el_htmlroot) - self._fail_if_found( - root=el_htmlroot, path='.//link', namespaces=namespaces) - self._fail_if_found( - root=el_htmlroot, path='.//script', namespaces=namespaces) + self._fail_if_found(root=el_htmlroot, path=".//link", namespaces=namespaces) + self._fail_if_found(root=el_htmlroot, path=".//script", namespaces=namespaces) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1d94a5056..df276d90c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -53,6 +53,7 @@ def title(self): def content(self): raise Exception + @override_settings(DEBUG=True) class DebugToolbarTestCase(BaseTestCase): def test_show_toolbar(self): From 073748cf12728eccb0aa7083b5ac4c5e6ffb19a2 Mon Sep 17 00:00:00 2001 From: tschilling Date: Fri, 2 Aug 2024 08:14:58 -0500 Subject: [PATCH 5/8] Improve testing of django-csp integration Some of these changes are stylistic, such as renaming _get_ns to get_namespaces. Important changes: - Adds tests for specific panels that use scripts - Fixes redirects panel to actually use the nonce - Fetches the toolbar instance from the store rather than context --- debug_toolbar/panels/redirects.py | 6 +- debug_toolbar/toolbar.py | 2 + requirements_dev.txt | 1 + tests/test_csp_rendering.py | 99 ++++++++++++++++++++++--------- 4 files changed, 79 insertions(+), 29 deletions(-) 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/toolbar.py b/debug_toolbar/toolbar.py index b2541dfa0..35d789a53 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -5,6 +5,8 @@ import re import uuid from functools import lru_cache + +# Can be removed when python3.8 is dropped from typing import OrderedDict from django.apps import apps 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/test_csp_rendering.py b/tests/test_csp_rendering.py index d20c2bb07..60e5314d8 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -2,15 +2,16 @@ 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 BaseTestCase -def _get_ns(element: Element) -> Dict[str, str]: +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 @@ -20,10 +21,12 @@ def _get_ns(element: Element) -> Dict[str, str]: return {"": element.tag[1:].split("}", maxsplit=1)[0]} +@override_settings(DEBUG=True) class CspRenderingTestCase(BaseTestCase): - "Testing if `csp-nonce` renders." + """Testing if `csp-nonce` renders.""" - panel_id = "StaticFilesPanel" + def setUp(self): + self.parser = HTMLParser() def _fail_if_missing( self, root: Element, path: str, namespaces: Dict[str, str], nonce: str @@ -46,50 +49,90 @@ def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]): raise self.failureException(f"{item} has no nonce attribute.") def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser): - "Fail if the passed HTML is invalid." + """Fail if the passed HTML is invalid.""" if parser.errors: default_msg = ["Content is invalid HTML:"] lines = content.split(b"\n") - for position, errorcode, datavars in parser.errors: - default_msg.append(" %s" % E[errorcode] % datavars) + 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( - DEBUG=True, MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] + MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] ) def test_exists(self): - "A `nonce` should exists when using the `CSPMiddleware`." + """A `nonce` should exist when using the `CSPMiddleware`.""" response = self.client.get(path="/regular/basic/") - if not isinstance(response, HttpResponse): - raise self.failureException(f"{response!r} is not a HttpResponse") self.assertEqual(response.status_code, 200) - parser = HTMLParser() - el_htmlroot: Element = parser.parse(stream=response.content) - self._fail_on_invalid_html(content=response.content, parser=parser) + + 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_ns(element=el_htmlroot) - context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue] + + 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 = self.client.get("/redirect/") + 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 nonce = str(context["toolbar"].request.csp_nonce) self._fail_if_missing( - root=el_htmlroot, path=".//link", namespaces=namespaces, nonce=nonce + root=html_root, path=".//link", namespaces=namespaces, nonce=nonce ) self._fail_if_missing( - root=el_htmlroot, path=".//script", namespaces=namespaces, nonce=nonce + root=html_root, path=".//script", namespaces=namespaces, nonce=nonce ) - @override_settings(DEBUG=True) + @override_settings( + MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] + ) + def test_panel_content_nonce_exists(self): + response = self.client.get("/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`." + """A `nonce` should not exist when not using the `CSPMiddleware`.""" response = self.client.get(path="/regular/basic/") - if not isinstance(response, HttpResponse): - raise self.failureException(f"{response!r} is not a HttpResponse") self.assertEqual(response.status_code, 200) - parser = HTMLParser() - el_htmlroot: Element = parser.parse(stream=response.content) - self._fail_on_invalid_html(content=response.content, parser=parser) + + 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_ns(element=el_htmlroot) - self._fail_if_found(root=el_htmlroot, path=".//link", namespaces=namespaces) - self._fail_if_found(root=el_htmlroot, path=".//script", namespaces=namespaces) + + 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) From ad4b463a7ffb850cb45ddde0f4241dcc9aa8977a Mon Sep 17 00:00:00 2001 From: tschilling Date: Fri, 2 Aug 2024 08:21:34 -0500 Subject: [PATCH 6/8] Use IntegrationTestCase as it clears the store. --- tests/test_csp_rendering.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index 60e5314d8..b54103730 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -8,7 +8,7 @@ from debug_toolbar.toolbar import DebugToolbar -from .base import BaseTestCase +from .base import IntegrationTestCase def get_namespaces(element: Element) -> Dict[str, str]: @@ -22,10 +22,11 @@ def get_namespaces(element: Element) -> Dict[str, str]: @override_settings(DEBUG=True) -class CspRenderingTestCase(BaseTestCase): +class CspRenderingTestCase(IntegrationTestCase): """Testing if `csp-nonce` renders.""" def setUp(self): + super().setUp() self.parser = HTMLParser() def _fail_if_missing( From 7da5f264c24fa75190dcd1ac6b4d700886312fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Fri, 2 Aug 2024 15:27:48 +0200 Subject: [PATCH 7/8] Fix typing errors --- tests/test_csp_rendering.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index b54103730..12385aeae 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -1,7 +1,8 @@ -from typing import Dict +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 @@ -47,7 +48,7 @@ def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]): elements = root.findall(path=path, namespaces=namespaces) for item in elements: if "nonce" in item.attrib: - raise self.failureException(f"{item} has no nonce attribute.") + 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.""" @@ -65,7 +66,7 @@ def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser): ) def test_exists(self): """A `nonce` should exist when using the `CSPMiddleware`.""" - response = self.client.get(path="/regular/basic/") + response = cast(HttpResponse, self.client.get(path="/regular/basic/")) self.assertEqual(response.status_code, 200) html_root: Element = self.parser.parse(stream=response.content) @@ -87,7 +88,7 @@ def test_exists(self): MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"], ) def test_redirects_exists(self): - response = self.client.get("/redirect/") + response = cast(HttpResponse, self.client.get(path="/regular/basic/")) self.assertEqual(response.status_code, 200) html_root: Element = self.parser.parse(stream=response.content) @@ -95,7 +96,8 @@ def test_redirects_exists(self): self.assertContains(response, "djDebug") namespaces = get_namespaces(element=html_root) - context: ContextList = response.context + 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 @@ -108,7 +110,7 @@ def test_redirects_exists(self): MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"] ) def test_panel_content_nonce_exists(self): - response = self.client.get("/regular/basic/") + response = cast(HttpResponse, self.client.get(path="/regular/basic/")) self.assertEqual(response.status_code, 200) toolbar = list(DebugToolbar._store.values())[0] @@ -127,7 +129,7 @@ def test_panel_content_nonce_exists(self): def test_missing(self): """A `nonce` should not exist when not using the `CSPMiddleware`.""" - response = self.client.get(path="/regular/basic/") + response = cast(HttpResponse, self.client.get(path="/regular/basic/")) self.assertEqual(response.status_code, 200) html_root: Element = self.parser.parse(stream=response.content) From 1a6ff1143aa531821f25aad1a393228e4f46810d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:37:02 +0000 Subject: [PATCH 8/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_csp_rendering.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_csp_rendering.py b/tests/test_csp_rendering.py index 12385aeae..5e355b15a 100644 --- a/tests/test_csp_rendering.py +++ b/tests/test_csp_rendering.py @@ -96,8 +96,7 @@ def test_redirects_exists(self): self.assertContains(response, "djDebug") namespaces = get_namespaces(element=html_root) - context: ContextList = \ - response.context # pyright: ignore[reportAttributeAccessIssue] + 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