Skip to content

Commit

Permalink
Quick hack for including csp_nonces from requests into script tags (#…
Browse files Browse the repository at this point in the history
…1975)

Co-authored-by: tschilling <schillingt@better-simple.com>
  • Loading branch information
karolyi and tim-schilling authored Aug 5, 2024
1 parent 573a87b commit 173b387
Show file tree
Hide file tree
Showing 10 changed files with 162 additions and 8 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
6 changes: 5 additions & 1 deletion debug_toolbar/panels/redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions debug_toolbar/templates/debug_toolbar/base.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{% 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 type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/toolbar.js' %}" async></script>
{% endblock %}
<div id="djDebug" class="djdt-hidden" dir="ltr"
{% if not toolbar.should_render_panels %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h3>{{ panel.title }}</h3>
</div>
<div class="djDebugPanelContent">
{% if toolbar.should_render_panels %}
{% for script in panel.scripts %}<script type="module" src="{{ script }}" async></script>{% endfor %}
{% for script in panel.scripts %}<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{{ script }}" async></script>{% endfor %}
<div class="djdt-scroll">{{ panel.content }}</div>
{% else %}
<div class="djdt-loader"></div>
Expand Down
2 changes: 1 addition & 1 deletion debug_toolbar/templates/debug_toolbar/redirect.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<html lang="en">
<head>
<title>Django Debug Toolbar Redirects Panel: {{ status_line }}</title>
<script type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
<script{% if toolbar.request.csp_nonce %} nonce="{{ toolbar.request.csp_nonce }}"{% endif %} type="module" src="{% static 'debug_toolbar/js/redirect.js' %}" async></script>
</head>
<body>
<h1>{{ status_line }}</h1>
Expand Down
7 changes: 5 additions & 2 deletions debug_toolbar/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ html5lib
selenium
tox
black
django-csp # Used in tests/test_csp_rendering

# Integration support

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
140 changes: 140 additions & 0 deletions tests/test_csp_rendering.py
Original file line number Diff line number Diff line change
@@ -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)
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 173b387

Please sign in to comment.