Skip to content

Commit

Permalink
Add rendering tests & nonces on link/style tags
Browse files Browse the repository at this point in the history
  • Loading branch information
karolyi committed Aug 1, 2024
1 parent fbbfca9 commit ba4ae50
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ geckodriver.log
coverage.xml
.direnv/
.envrc
venv
4 changes: 2 additions & 2 deletions debug_toolbar/templates/debug_toolbar/base.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% load i18n static %}
{% block css %}
<link rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
<link rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.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' %}">
{% 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>
Expand Down
3 changes: 2 additions & 1 deletion debug_toolbar/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion tests/panels/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions tests/test_csp_rendering.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def title(self):
def content(self):
raise Exception


@override_settings(DEBUG=True)
class DebugToolbarTestCase(BaseTestCase):
def test_show_toolbar(self):
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ deps =
pygments
selenium>=4.8.0
sqlparse
django-csp
passenv=
CI
COVERAGE_ARGS
Expand Down

0 comments on commit ba4ae50

Please sign in to comment.