Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quick hack for including csp_nonces from requests into script tags #1975

Merged
merged 8 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Comment on lines +99 to +100
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
context: ContextList = response.context # pyright: ignore[reportAttributeAccessIssue]
nonce = str(context["toolbar"].request.csp_nonce)
toolbar = list(DebugToolbar._store.values())[0]
nonce = str(toolbar.request.csp_nonce)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here as above.

It might be inconvenient for others, but I can assure you I have to fight with these kinda errors throughout everywhere when working with django, even when having the stubs installed.

It is not perfect but it's the best we have. I have already made the compromise to have my code "autocorrected" (in the sense of might makes right), so I suggest others make compromises here too.

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