Skip to content

Commit 173b387

Browse files
Quick hack for including csp_nonces from requests into script tags (#1975)
Co-authored-by: tschilling <[email protected]>
1 parent 573a87b commit 173b387

File tree

10 files changed

+162
-8
lines changed

10 files changed

+162
-8
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ geckodriver.log
1414
coverage.xml
1515
.direnv/
1616
.envrc
17+
venv

debug_toolbar/panels/redirects.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ def process_request(self, request):
2121
if redirect_to:
2222
status_line = f"{response.status_code} {response.reason_phrase}"
2323
cookies = response.cookies
24-
context = {"redirect_to": redirect_to, "status_line": status_line}
24+
context = {
25+
"redirect_to": redirect_to,
26+
"status_line": status_line,
27+
"toolbar": self.toolbar,
28+
}
2529
# Using SimpleTemplateResponse avoids running global context processors.
2630
response = SimpleTemplateResponse(
2731
"debug_toolbar/redirect.html", context

debug_toolbar/templates/debug_toolbar/base.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{% load i18n static %}
22
{% block css %}
3-
<link rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
4-
<link rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
3+
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" media="print">
4+
<link{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}">
55
{% endblock %}
66
{% block js %}
7-
<script type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
7+
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
88
{% endblock %}
99
<div id="djDebug" class="djdt-hidden" dir="ltr"
1010
{% if not toolbar.should_render_panels %}

debug_toolbar/templates/debug_toolbar/includes/panel_content.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ <h3>{{ panel.title }}</h3>
88
</div>
99
<div class="djDebugPanelContent">
1010
{% if toolbar.should_render_panels %}
11-
{% for script in panel.scripts %}<script type="module" src="{{ script }}" async></script>{% endfor %}
11+
{% for script in panel.scripts %}<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
1212
<div class="djdt-scroll">{{ panel.content }}</div>
1313
{% else %}
1414
<div class="djdt-loader"></div>

debug_toolbar/templates/debug_toolbar/redirect.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<html lang="en">
44
<head>
55
<title>Django Debug Toolbar Redirects Panel: {{ status_line }}</title>
6-
<script type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
6+
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
77
</head>
88
<body>
99
<h1>{{ status_line }}</h1>

debug_toolbar/toolbar.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import re
66
import uuid
7-
from collections import OrderedDict
87
from functools import lru_cache
98

9+
# Can be removed when python3.8 is dropped
10+
from typing import OrderedDict
11+
1012
from django.apps import apps
1113
from django.conf import settings
1214
from django.core.exceptions import ImproperlyConfigured
@@ -19,6 +21,7 @@
1921
from django.utils.translation import get_language, override as lang_override
2022

2123
from debug_toolbar import APP_NAME, settings as dt_settings
24+
from debug_toolbar.panels import Panel
2225

2326

2427
class DebugToolbar:
@@ -38,7 +41,7 @@ def __init__(self, request, get_response):
3841
# Use OrderedDict for the _panels attribute so that items can be efficiently
3942
# removed using FIFO order in the DebugToolbar.store() method. The .popitem()
4043
# method of Python's built-in dict only supports LIFO removal.
41-
self._panels = OrderedDict()
44+
self._panels = OrderedDict[str, Panel]()
4245
while panels:
4346
panel = panels.pop()
4447
self._panels[panel.panel_id] = panel

requirements_dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ html5lib
1111
selenium
1212
tox
1313
black
14+
django-csp # Used in tests/test_csp_rendering
1415

1516
# Integration support
1617

tests/base.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
from typing import Optional
2+
13
import html5lib
24
from asgiref.local import Local
35
from django.http import HttpResponse
46
from django.test import Client, RequestFactory, TestCase, TransactionTestCase
57

8+
from debug_toolbar.panels import Panel
69
from debug_toolbar.toolbar import DebugToolbar
710

811

@@ -32,6 +35,7 @@ def handle_toolbar_created(sender, toolbar=None, **kwargs):
3235
class BaseMixin:
3336
client_class = ToolbarTestClient
3437

38+
panel: Optional[Panel] = None
3539
panel_id = None
3640

3741
def setUp(self):

tests/test_csp_rendering.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from typing import Dict, cast
2+
from xml.etree.ElementTree import Element
3+
4+
from django.conf import settings
5+
from django.http.response import HttpResponse
6+
from django.test.utils import ContextList, override_settings
7+
from html5lib.constants import E
8+
from html5lib.html5parser import HTMLParser
9+
10+
from debug_toolbar.toolbar import DebugToolbar
11+
12+
from .base import IntegrationTestCase
13+
14+
15+
def get_namespaces(element: Element) -> Dict[str, str]:
16+
"""
17+
Return the default `xmlns`. See
18+
https://docs.python.org/3/library/xml.etree.elementtree.html#parsing-xml-with-namespaces
19+
"""
20+
if not element.tag.startswith("{"):
21+
return {}
22+
return {"": element.tag[1:].split("}", maxsplit=1)[0]}
23+
24+
25+
@override_settings(DEBUG=True)
26+
class CspRenderingTestCase(IntegrationTestCase):
27+
"""Testing if `csp-nonce` renders."""
28+
29+
def setUp(self):
30+
super().setUp()
31+
self.parser = HTMLParser()
32+
33+
def _fail_if_missing(
34+
self, root: Element, path: str, namespaces: Dict[str, str], nonce: str
35+
):
36+
"""
37+
Search elements, fail if a `nonce` attribute is missing on them.
38+
"""
39+
elements = root.findall(path=path, namespaces=namespaces)
40+
for item in elements:
41+
if item.attrib.get("nonce") != nonce:
42+
raise self.failureException(f"{item} has no nonce attribute.")
43+
44+
def _fail_if_found(self, root: Element, path: str, namespaces: Dict[str, str]):
45+
"""
46+
Search elements, fail if a `nonce` attribute is found on them.
47+
"""
48+
elements = root.findall(path=path, namespaces=namespaces)
49+
for item in elements:
50+
if "nonce" in item.attrib:
51+
raise self.failureException(f"{item} has a nonce attribute.")
52+
53+
def _fail_on_invalid_html(self, content: bytes, parser: HTMLParser):
54+
"""Fail if the passed HTML is invalid."""
55+
if parser.errors:
56+
default_msg = ["Content is invalid HTML:"]
57+
lines = content.split(b"\n")
58+
for position, error_code, data_vars in parser.errors:
59+
default_msg.append(" %s" % E[error_code] % data_vars)
60+
default_msg.append(" %r" % lines[position[0] - 1])
61+
msg = self._formatMessage(None, "\n".join(default_msg))
62+
raise self.failureException(msg)
63+
64+
@override_settings(
65+
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
66+
)
67+
def test_exists(self):
68+
"""A `nonce` should exist when using the `CSPMiddleware`."""
69+
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
70+
self.assertEqual(response.status_code, 200)
71+
72+
html_root: Element = self.parser.parse(stream=response.content)
73+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
74+
self.assertContains(response, "djDebug")
75+
76+
namespaces = get_namespaces(element=html_root)
77+
toolbar = list(DebugToolbar._store.values())[0]
78+
nonce = str(toolbar.request.csp_nonce)
79+
self._fail_if_missing(
80+
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
81+
)
82+
self._fail_if_missing(
83+
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
84+
)
85+
86+
@override_settings(
87+
DEBUG_TOOLBAR_CONFIG={"DISABLE_PANELS": set()},
88+
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"],
89+
)
90+
def test_redirects_exists(self):
91+
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
92+
self.assertEqual(response.status_code, 200)
93+
94+
html_root: Element = self.parser.parse(stream=response.content)
95+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
96+
self.assertContains(response, "djDebug")
97+
98+
namespaces = get_namespaces(element=html_root)
99+
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
100+
nonce = str(context["toolbar"].request.csp_nonce)
101+
self._fail_if_missing(
102+
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
103+
)
104+
self._fail_if_missing(
105+
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
106+
)
107+
108+
@override_settings(
109+
MIDDLEWARE=settings.MIDDLEWARE + ["csp.middleware.CSPMiddleware"]
110+
)
111+
def test_panel_content_nonce_exists(self):
112+
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
113+
self.assertEqual(response.status_code, 200)
114+
115+
toolbar = list(DebugToolbar._store.values())[0]
116+
panels_to_check = ["HistoryPanel", "TimerPanel"]
117+
for panel in panels_to_check:
118+
content = toolbar.get_panel_by_id(panel).content
119+
html_root: Element = self.parser.parse(stream=content)
120+
namespaces = get_namespaces(element=html_root)
121+
nonce = str(toolbar.request.csp_nonce)
122+
self._fail_if_missing(
123+
root=html_root, path=".//link", namespaces=namespaces, nonce=nonce
124+
)
125+
self._fail_if_missing(
126+
root=html_root, path=".//script", namespaces=namespaces, nonce=nonce
127+
)
128+
129+
def test_missing(self):
130+
"""A `nonce` should not exist when not using the `CSPMiddleware`."""
131+
response = cast(HttpResponse, self.client.get(path="/regular/basic/"))
132+
self.assertEqual(response.status_code, 200)
133+
134+
html_root: Element = self.parser.parse(stream=response.content)
135+
self._fail_on_invalid_html(content=response.content, parser=self.parser)
136+
self.assertContains(response, "djDebug")
137+
138+
namespaces = get_namespaces(element=html_root)
139+
self._fail_if_found(root=html_root, path=".//link", namespaces=namespaces)
140+
self._fail_if_found(root=html_root, path=".//script", namespaces=namespaces)

tox.ini

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ deps =
2121
pygments
2222
selenium>=4.8.0
2323
sqlparse
24+
django-csp
2425
passenv=
2526
CI
2627
COVERAGE_ARGS

0 commit comments

Comments
 (0)