From 5e6750bf89e834fa961b4abdbfe361f7ea1b04ab Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 14 Apr 2021 10:08:02 -0500 Subject: [PATCH] Merge pull request from GHSA-pghf-347x-c2gj * Changes required to support v2.2.x branch Style corrections for tox. Remove djmaster from tox requirements. Switch to Github actions and latest toolbar tox/settings approach. Skip tests that are invalid for old versions. * Fix CVE-2021-30459 by creating signature from all data fields. Backport of 1c6ba3c1302bf545f8356dcd26255ab7db1ec408 Create a signature based on all fields in the form and attach to validate that the data being sent back is what the server generated initially. Change the hashing algorithm to SHA256 Force the values to a string for signing. Remove hashing mechanism from forms. Support sha1 algorithm for django < 3.1 * Bump to version 2.2.1 --- .github/workflows/test.yml | 196 ++++++++++++++++++++++++ README.rst | 2 +- debug_toolbar/decorators.py | 20 ++- debug_toolbar/forms.py | 54 +++++++ debug_toolbar/panels/cache.py | 26 ++-- debug_toolbar/panels/history/forms.py | 11 ++ debug_toolbar/panels/history/panel.py | 102 ++++++++++++ debug_toolbar/panels/history/views.py | 61 ++++++++ debug_toolbar/panels/signals.py | 24 +-- debug_toolbar/panels/sql/forms.py | 26 ---- debug_toolbar/panels/sql/panel.py | 18 ++- debug_toolbar/panels/sql/views.py | 17 +- debug_toolbar/panels/templates/views.py | 2 +- docs/changes.rst | 6 + docs/conf.py | 4 +- setup.py | 2 +- tests/commands/test_debugsqlshell.py | 5 + tests/panels/test_sql.py | 12 +- tests/settings.py | 21 +-- tests/test_forms.py | 56 +++++++ tests/test_integration.py | 61 +++++--- tox.ini | 76 ++++++--- 22 files changed, 679 insertions(+), 123 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 debug_toolbar/forms.py create mode 100644 debug_toolbar/panels/history/forms.py create mode 100644 debug_toolbar/panels/history/panel.py create mode 100644 debug_toolbar/panels/history/views.py create mode 100644 tests/test_forms.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..d75dec6cb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,196 @@ +name: Test + +on: [push, pull_request] + +jobs: + mysql: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + services: + mariadb: + image: mariadb:10.3 + env: + MYSQL_ROOT_PASSWORD: debug_toolbar + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: mysql + DB_USER: root + DB_PASSWORD: debug_toolbar + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + postgres: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + services: + postgres: + image: 'postgres:9.5' + env: + POSTGRES_DB: debug_toolbar + POSTGRES_USER: debug_toolbar + POSTGRES_PASSWORD: debug_toolbar + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: postgresql + DB_HOST: localhost + DB_PORT: 5432 + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + sqlite: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: sqlite3 + DB_NAME: ":memory:" + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox + - name: Test with tox + run: tox -e style,readme \ No newline at end of file diff --git a/README.rst b/README.rst index 0595d47bc..b1bf5b557 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Here's a screenshot of the toolbar in action: In addition to the built-in panels, a number of third-party panels are contributed by the community. -The current stable version of the Debug Toolbar is 2.2. It works on +The current stable version of the Debug Toolbar is 2.2.1. It works on Django ≥ 1.11. Documentation, including installation and configuration instructions, is diff --git a/debug_toolbar/decorators.py b/debug_toolbar/decorators.py index 8114b05d7..2abfb22f9 100644 --- a/debug_toolbar/decorators.py +++ b/debug_toolbar/decorators.py @@ -1,6 +1,6 @@ import functools -from django.http import Http404 +from django.http import Http404, HttpResponseBadRequest def require_show_toolbar(view): @@ -15,3 +15,21 @@ def inner(request, *args, **kwargs): return view(request, *args, **kwargs) return inner + + +def signed_data_view(view): + """Decorator that handles unpacking a signed data form""" + + @functools.wraps(view) + def inner(request, *args, **kwargs): + from debug_toolbar.forms import SignedDataForm + + data = request.GET if request.method == "GET" else request.POST + signed_form = SignedDataForm(data) + if signed_form.is_valid(): + return view( + request, *args, verified_data=signed_form.verified_data(), **kwargs + ) + return HttpResponseBadRequest("Invalid signature") + + return inner diff --git a/debug_toolbar/forms.py b/debug_toolbar/forms.py new file mode 100644 index 000000000..06d25dfaa --- /dev/null +++ b/debug_toolbar/forms.py @@ -0,0 +1,54 @@ +import json +from collections import OrderedDict + +from django import forms +from django.core import signing +from django.core.exceptions import ValidationError +from django.utils.encoding import force_str + + +class SignedDataForm(forms.Form): + """Helper form that wraps a form to validate its contents on post. + + class PanelForm(forms.Form): + # fields + + On render: + form = SignedDataForm(initial=PanelForm(initial=data).initial) + + On POST: + signed_form = SignedDataForm(request.POST) + if signed_form.is_valid(): + panel_form = PanelForm(signed_form.verified_data) + if panel_form.is_valid(): + # Success + Or wrap the FBV with ``debug_toolbar.decorators.signed_data_view`` + """ + + salt = "django_debug_toolbar" + signed = forms.CharField(required=True, widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + initial = kwargs.pop("initial", None) + if initial: + initial = {"signed": self.sign(initial)} + super().__init__(*args, initial=initial, **kwargs) + + def clean_signed(self): + try: + verified = json.loads( + signing.Signer(salt=self.salt).unsign(self.cleaned_data["signed"]) + ) + return verified + except signing.BadSignature: + raise ValidationError("Bad signature") + + def verified_data(self): + return self.is_valid() and self.cleaned_data["signed"] + + @classmethod + def sign(cls, data): + items = sorted(data.items(), key=lambda item: item[0]) + return signing.Signer(salt=cls.salt).sign( + json.dumps(OrderedDict((key, force_str(value)) for key, value in items)) + ) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 91f51f6fb..97d03e2c8 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -216,20 +216,26 @@ def _store_call_info( @property def nav_subtitle(self): cache_calls = len(self.calls) - return __( - "%(cache_calls)d call in %(time).2fms", - "%(cache_calls)d calls in %(time).2fms", - cache_calls, - ) % {"cache_calls": cache_calls, "time": self.total_time} + return ( + __( + "%(cache_calls)d call in %(time).2fms", + "%(cache_calls)d calls in %(time).2fms", + cache_calls, + ) + % {"cache_calls": cache_calls, "time": self.total_time} + ) @property def title(self): count = len(getattr(settings, "CACHES", ["default"])) - return __( - "Cache calls from %(count)d backend", - "Cache calls from %(count)d backends", - count, - ) % {"count": count} + return ( + __( + "Cache calls from %(count)d backend", + "Cache calls from %(count)d backends", + count, + ) + % {"count": count} + ) def enable_instrumentation(self): if isinstance(middleware_cache.caches, CacheHandlerPatch): diff --git a/debug_toolbar/panels/history/forms.py b/debug_toolbar/panels/history/forms.py new file mode 100644 index 000000000..9280c3cc9 --- /dev/null +++ b/debug_toolbar/panels/history/forms.py @@ -0,0 +1,11 @@ +from django import forms + + +class HistoryStoreForm(forms.Form): + """ + Validate params + + store_id: The key for the store instance to be fetched. + """ + + store_id = forms.CharField(widget=forms.HiddenInput()) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py new file mode 100644 index 000000000..4494bbfcd --- /dev/null +++ b/debug_toolbar/panels/history/panel.py @@ -0,0 +1,102 @@ +import json +from collections import OrderedDict + +from django.http.request import RawPostDataException +from django.template.loader import render_to_string +from django.templatetags.static import static +from django.urls import path +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.forms import SignedDataForm +from debug_toolbar.panels import Panel +from debug_toolbar.panels.history import views +from debug_toolbar.panels.history.forms import HistoryStoreForm + + +class HistoryPanel(Panel): + """ A panel to display History """ + + title = _("History") + nav_title = _("History") + template = "debug_toolbar/panels/history.html" + + @property + def is_historical(self): + """The HistoryPanel should not be included in the historical panels.""" + return False + + @classmethod + def get_urls(cls): + return [ + path("history_sidebar/", views.history_sidebar, name="history_sidebar"), + path("history_refresh/", views.history_refresh, name="history_refresh"), + ] + + @property + def nav_subtitle(self): + return self.get_stats().get("request_url", "") + + def generate_stats(self, request, response): + try: + if request.method == "GET": + data = request.GET.copy() + else: + data = request.POST.copy() + # GraphQL tends to not be populated in POST. If the request seems + # empty, check if it's a JSON request. + if ( + not data + and request.body + and request.META.get("CONTENT_TYPE") == "application/json" + ): + try: + data = json.loads(request.body) + except ValueError: + pass + except RawPostDataException: + # It is not guaranteed that we may read the request data (again). + data = None + + self.record_stats( + { + "request_url": request.get_full_path(), + "request_method": request.method, + "data": data, + "time": timezone.now(), + } + ) + + @property + def content(self): + """Content of the panel when it's displayed in full screen. + + Fetch every store for the toolbar and include it in the template. + """ + stores = OrderedDict() + for id, toolbar in reversed(self.toolbar._store.items()): + stores[id] = { + "toolbar": toolbar, + "form": SignedDataForm( + initial=HistoryStoreForm(initial={"store_id": id}).initial + ), + } + + return render_to_string( + self.template, + { + "current_store_id": self.toolbar.store_id, + "stores": stores, + "refresh_form": SignedDataForm( + initial=HistoryStoreForm( + initial={"store_id": self.toolbar.store_id} + ).initial + ), + }, + ) + + @property + def scripts(self): + scripts = super().scripts + scripts.append(static("debug_toolbar/js/history.js")) + return scripts diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py new file mode 100644 index 000000000..b4cf8c835 --- /dev/null +++ b/debug_toolbar/panels/history/views.py @@ -0,0 +1,61 @@ +from django.http import HttpResponseBadRequest, JsonResponse +from django.template.loader import render_to_string + +from debug_toolbar.decorators import require_show_toolbar, signed_data_view +from debug_toolbar.panels.history.forms import HistoryStoreForm +from debug_toolbar.toolbar import DebugToolbar + + +@require_show_toolbar +@signed_data_view +def history_sidebar(request, verified_data): + """Returns the selected debug toolbar history snapshot.""" + form = HistoryStoreForm(verified_data) + + if form.is_valid(): + store_id = form.cleaned_data["store_id"] + toolbar = DebugToolbar.fetch(store_id) + context = {} + for panel in toolbar.panels: + if not panel.is_historical: + continue + panel_context = {"panel": panel} + context[panel.panel_id] = { + "button": render_to_string( + "debug_toolbar/includes/panel_button.html", panel_context + ), + "content": render_to_string( + "debug_toolbar/includes/panel_content.html", panel_context + ), + } + return JsonResponse(context) + return HttpResponseBadRequest("Form errors") + + +@require_show_toolbar +@signed_data_view +def history_refresh(request, verified_data): + """Returns the refreshed list of table rows for the History Panel.""" + form = HistoryStoreForm(verified_data) + + if form.is_valid(): + requests = [] + for id, toolbar in reversed(DebugToolbar._store.items()): + requests.append( + { + "id": id, + "content": render_to_string( + "debug_toolbar/panels/history_tr.html", + { + "id": id, + "store_context": { + "toolbar": toolbar, + "form": HistoryStoreForm(initial={"store_id": id}), + }, + }, + ), + } + ) + + return JsonResponse({"requests": requests}) + return HttpResponseBadRequest("Form errors") diff --git a/debug_toolbar/panels/signals.py b/debug_toolbar/panels/signals.py index b707a2bd1..5f932a0d4 100644 --- a/debug_toolbar/panels/signals.py +++ b/debug_toolbar/panels/signals.py @@ -43,16 +43,22 @@ def nav_subtitle(self): # here we have to handle a double count translation, hence the # hard coding of one signal if num_signals == 1: - return __( - "%(num_receivers)d receiver of 1 signal", - "%(num_receivers)d receivers of 1 signal", + return ( + __( + "%(num_receivers)d receiver of 1 signal", + "%(num_receivers)d receivers of 1 signal", + num_receivers, + ) + % {"num_receivers": num_receivers} + ) + return ( + __( + "%(num_receivers)d receiver of %(num_signals)d signals", + "%(num_receivers)d receivers of %(num_signals)d signals", num_receivers, - ) % {"num_receivers": num_receivers} - return __( - "%(num_receivers)d receiver of %(num_signals)d signals", - "%(num_receivers)d receivers of %(num_signals)d signals", - num_receivers, - ) % {"num_receivers": num_receivers, "num_signals": num_signals} + ) + % {"num_receivers": num_receivers, "num_signals": num_signals} + ) title = _("Signals") diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 4131cb775..a69b47519 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -1,13 +1,8 @@ -import hashlib -import hmac import json from django import forms -from django.conf import settings from django.core.exceptions import ValidationError from django.db import connections -from django.utils.crypto import constant_time_compare -from django.utils.encoding import force_bytes from django.utils.functional import cached_property from debug_toolbar.panels.sql.utils import reformat_sql @@ -21,7 +16,6 @@ class SQLSelectForm(forms.Form): raw_sql: The sql statement with placeholders params: JSON encoded parameter values duration: time for SQL to execute passed in from toolbar just for redisplay - hash: the hash of (secret + sql + params) for tamper checking """ sql = forms.CharField() @@ -29,14 +23,8 @@ class SQLSelectForm(forms.Form): params = forms.CharField() alias = forms.CharField(required=False, initial="default") duration = forms.FloatField() - hash = forms.CharField() def __init__(self, *args, **kwargs): - initial = kwargs.get("initial", None) - - if initial is not None: - initial["hash"] = self.make_hash(initial) - super().__init__(*args, **kwargs) for name in self.fields: @@ -66,23 +54,9 @@ def clean_alias(self): return value - def clean_hash(self): - hash = self.cleaned_data["hash"] - - if not constant_time_compare(hash, self.make_hash(self.data)): - raise ValidationError("Tamper alert") - - return hash - def reformat_sql(self): return reformat_sql(self.cleaned_data["sql"], with_toggle=False) - def make_hash(self, data): - m = hmac.new(key=force_bytes(settings.SECRET_KEY), digestmod=hashlib.sha1) - for item in [data["sql"], data["params"]]: - m.update(force_bytes(item)) - return m.hexdigest() - @property def connection(self): return connections[self.cleaned_data["alias"]] diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 0280a06e6..5f717e803 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -7,6 +7,7 @@ from django.db import connections from django.utils.translation import gettext_lazy as _, ngettext_lazy as __ +from debug_toolbar.forms import SignedDataForm from debug_toolbar.panels import Panel from debug_toolbar.panels.sql import views from debug_toolbar.panels.sql.forms import SQLSelectForm @@ -117,11 +118,14 @@ def nav_subtitle(self): @property def title(self): count = len(self._databases) - return __( - "SQL queries from %(count)d connection", - "SQL queries from %(count)d connections", - count, - ) % {"count": count} + return ( + __( + "SQL queries from %(count)d connection", + "SQL queries from %(count)d connections", + count, + ) + % {"count": count} + ) template = "debug_toolbar/panels/sql.html" @@ -208,7 +212,9 @@ def duplicate_key(query): query["vendor"], query["trans_status"] ) - query["form"] = SQLSelectForm(auto_id=None, initial=copy(query)) + query["form"] = SignedDataForm( + auto_id=None, initial=SQLSelectForm(initial=copy(query)).initial + ) if query["sql"]: query["sql"] = reformat_sql(query["sql"], with_toggle=True) diff --git a/debug_toolbar/panels/sql/views.py b/debug_toolbar/panels/sql/views.py index de525a87c..db2f0aae8 100644 --- a/debug_toolbar/panels/sql/views.py +++ b/debug_toolbar/panels/sql/views.py @@ -2,15 +2,16 @@ from django.template.response import SimpleTemplateResponse from django.views.decorators.csrf import csrf_exempt -from debug_toolbar.decorators import require_show_toolbar +from debug_toolbar.decorators import require_show_toolbar, signed_data_view from debug_toolbar.panels.sql.forms import SQLSelectForm @csrf_exempt @require_show_toolbar -def sql_select(request): +@signed_data_view +def sql_select(request, verified_data): """Returns the output of the SQL SELECT statement""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] @@ -34,9 +35,10 @@ def sql_select(request): @csrf_exempt @require_show_toolbar -def sql_explain(request): +@signed_data_view +def sql_explain(request, verified_data): """Returns the output of the SQL EXPLAIN on the given query""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] @@ -71,9 +73,10 @@ def sql_explain(request): @csrf_exempt @require_show_toolbar -def sql_profile(request): +@signed_data_view +def sql_profile(request, verified_data): """Returns the output of running the SQL and getting the profiling statistics""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] diff --git a/debug_toolbar/panels/templates/views.py b/debug_toolbar/panels/templates/views.py index 2b3089798..38a31766d 100644 --- a/debug_toolbar/panels/templates/views.py +++ b/debug_toolbar/panels/templates/views.py @@ -48,8 +48,8 @@ def template_source(request): try: from pygments import highlight - from pygments.lexers import HtmlDjangoLexer from pygments.formatters import HtmlFormatter + from pygments.lexers import HtmlDjangoLexer source = highlight(source, HtmlDjangoLexer(), HtmlFormatter()) source = mark_safe(source) diff --git a/docs/changes.rst b/docs/changes.rst index bf4d555e1..f01a1f365 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,12 @@ Change log UNRELEASED ---------- +2.2.1 (2021-04-14) +------------------ + +* Fixed SQL Injection vulnerability, CVE-2021-30459. The toolbar now + calculates a signature on all fields for the SQL select, explain, + and analyze forms. 2.2 (2020-01-31) ---------------- diff --git a/docs/conf.py b/docs/conf.py index c93c3947b..33f0eb983 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,9 +59,9 @@ # built documents. # # The short X.Y version. -version = '2.2' +version = '2.2.1' # The full version, including alpha/beta/rc tags. -release = '2.2' +release = '2.2.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index a63a108fd..583cf9471 100755 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ def readall(path): setup( name="django-debug-toolbar", - version="2.2", + version="2.2.1", description="A configurable set of panels that display various debug " "information about the current request/response.", long_description=readall("README.rst"), diff --git a/tests/commands/test_debugsqlshell.py b/tests/commands/test_debugsqlshell.py index 9520d0dd8..28eab84cc 100644 --- a/tests/commands/test_debugsqlshell.py +++ b/tests/commands/test_debugsqlshell.py @@ -1,5 +1,6 @@ import io import sys +import unittest import django from django.contrib.auth.models import User @@ -15,6 +16,10 @@ @override_settings(DEBUG=True) +@unittest.skipIf( + django.VERSION < (2, 1) and connection.vendor == "mysql", + "There's a bug with MySQL and Django 2.0.X that fails this test.", +) class DebugSQLShellTestCase(TestCase): def setUp(self): self.original_wrapper = base_module.CursorDebugWrapper diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index e1fd8ef42..310c66d79 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -1,4 +1,5 @@ import datetime +import sys import unittest import django @@ -144,12 +145,18 @@ def test_json_param_conversion(self): # ensure query was logged self.assertEqual(len(self.panel._queries), 1) self.assertEqual( - self.panel._queries[0][1]["params"], '["{\\"foo\\": \\"bar\\"}"]', + self.panel._queries[0][1]["params"], + '["{\\"foo\\": \\"bar\\"}"]', ) self.assertIsInstance( - self.panel._queries[0][1]["raw_params"][0], PostgresJson, + self.panel._queries[0][1]["raw_params"][0], + PostgresJson, ) + @unittest.skipIf( + django.VERSION < (2, 1) and connection.vendor == "mysql", + "There's a bug with MySQL and Django 2.0.X that fails this test.", + ) def test_binary_param_force_text(self): self.assertEqual(len(self.panel._queries), 0) @@ -170,6 +177,7 @@ def test_binary_param_force_text(self): ) @unittest.skipUnless(connection.vendor != "sqlite", "Test invalid for SQLite") + @unittest.skipIf(sys.version_info[0:2] < (3, 6), "Dicts are unordered before 3.6") def test_raw_query_param_conversion(self): self.assertEqual(len(self.panel._queries), 0) diff --git a/tests/settings.py b/tests/settings.py index dbbbb79b2..deb7d6bba 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -79,16 +79,17 @@ "second": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, } -if os.environ.get("DJANGO_DATABASE_ENGINE") == "postgresql": - DATABASES = { - "default": {"ENGINE": "django.db.backends.postgresql", "NAME": "debug-toolbar"} - } -elif os.environ.get("DJANGO_DATABASE_ENGINE") == "mysql": - DATABASES = { - "default": {"ENGINE": "django.db.backends.mysql", "NAME": "debug_toolbar"} - } -else: - DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} +DATABASES = { + "default": { + "ENGINE": "django.db.backends.%s" % os.getenv("DB_BACKEND", "sqlite3"), + "NAME": os.getenv("DB_NAME", ":memory:"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST", ""), + "PORT": os.getenv("DB_PORT", ""), + "TEST": {"USER": "default_test"}, + }, +} # Debug Toolbar configuration diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 000000000..35a2db427 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,56 @@ +from datetime import datetime + +import django +from django import forms +from django.test import TestCase + +from debug_toolbar.forms import SignedDataForm + +# Django 3.1 uses sha256 by default. +SIGNATURE = ( + "v02QBcJplEET6QXHNWejnRcmSENWlw6_RjxLTR7QG9g" + if django.VERSION >= (3, 1) + else "ukcAFUqYhUUnqT-LupnYoo-KvFg" +) + +DATA = {"value": "foo", "date": datetime(2020, 1, 1)} +SIGNED_DATA = '{{"date": "2020-01-01 00:00:00", "value": "foo"}}:{}'.format(SIGNATURE) + + +class FooForm(forms.Form): + value = forms.CharField() + # Include a datetime in the tests because it's not serializable back + # to a datetime by SignedDataForm + date = forms.DateTimeField() + + +class TestSignedDataForm(TestCase): + def test_signed_data(self): + data = {"signed": SignedDataForm.sign(DATA)} + form = SignedDataForm(data=data) + self.assertTrue(form.is_valid()) + # Check the signature value + self.assertEqual(data["signed"], SIGNED_DATA) + + def test_verified_data(self): + form = SignedDataForm(data={"signed": SignedDataForm.sign(DATA)}) + self.assertEqual( + form.verified_data(), + { + "value": "foo", + "date": "2020-01-01 00:00:00", + }, + ) + # Take it back to the foo form to validate the datetime is serialized + foo_form = FooForm(data=form.verified_data()) + self.assertTrue(foo_form.is_valid()) + self.assertDictEqual(foo_form.cleaned_data, DATA) + + def test_initial_set_signed(self): + form = SignedDataForm(initial=DATA) + self.assertEqual(form.initial["signed"], SIGNED_DATA) + + def test_prevents_tampering(self): + data = {"signed": SIGNED_DATA.replace('"value": "foo"', '"value": "bar"')} + form = SignedDataForm(data=data) + self.assertFalse(form.is_valid()) diff --git a/tests/test_integration.py b/tests/test_integration.py index 94e5ac990..99b89275d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,6 +12,7 @@ from django.test import RequestFactory, SimpleTestCase, TestCase from django.test.utils import override_settings +from debug_toolbar.forms import SignedDataForm from debug_toolbar.middleware import DebugToolbarMiddleware, show_toolbar from debug_toolbar.toolbar import DebugToolbar @@ -164,12 +165,15 @@ def test_template_source_checks_show_toolbar(self): def test_sql_select_checks_show_toolbar(self): url = "/__debug__/sql_select/" data = { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", - "hash": "6e12daa636b8c9a8be993307135458f90a877606", + "signed": SignedDataForm.sign( + { + "sql": "SELECT * FROM auth_user", + "raw_sql": "SELECT * FROM auth_user", + "params": "{}", + "alias": "default", + "duration": "0", + } + ) } response = self.client.post(url, data) @@ -187,12 +191,15 @@ def test_sql_select_checks_show_toolbar(self): def test_sql_explain_checks_show_toolbar(self): url = "/__debug__/sql_explain/" data = { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", - "hash": "6e12daa636b8c9a8be993307135458f90a877606", + "signed": SignedDataForm.sign( + { + "sql": "SELECT * FROM auth_user", + "raw_sql": "SELECT * FROM auth_user", + "params": "{}", + "alias": "default", + "duration": "0", + } + ) } response = self.client.post(url, data) @@ -217,12 +224,15 @@ def test_sql_explain_postgres_json_field(self): ) query = base_query + """ '{"foo": "bar"}'""" data = { - "sql": query, - "raw_sql": base_query + " %s", - "params": '["{\\"foo\\": \\"bar\\"}"]', - "alias": "default", - "duration": "0", - "hash": "2b7172eb2ac8e2a8d6f742f8a28342046e0d00ba", + "signed": SignedDataForm.sign( + { + "sql": query, + "raw_sql": base_query + " %s", + "params": '["{\\"foo\\": \\"bar\\"}"]', + "alias": "default", + "duration": "0", + } + ) } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) @@ -239,12 +249,15 @@ def test_sql_explain_postgres_json_field(self): def test_sql_profile_checks_show_toolbar(self): url = "/__debug__/sql_profile/" data = { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", - "hash": "6e12daa636b8c9a8be993307135458f90a877606", + "signed": SignedDataForm.sign( + { + "sql": "SELECT * FROM auth_user", + "raw_sql": "SELECT * FROM auth_user", + "params": "{}", + "alias": "default", + "duration": "0", + } + ) } response = self.client.post(url, data) diff --git a/tox.ini b/tox.ini index f7d26a2e5..60ab5eed2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,55 +1,72 @@ [tox] envlist = - flake8 style readme - py{35,36,37}-dj111-sqlite - py{35,36,37,38}-dj22-sqlite - py{36,37,38}-dj30-sqlite - py{36,37,38}-djmaster-sqlite - py37-dj111-{postgresql,mariadb} - py{37,38}-dj{22,30}-{postgresql,mariadb} + py{35,36}-dj111-{sqlite,postgresql,mysql} + py{35}-dj{20,21,22}-{sqlite,postgresql,mysql} + py{36,37,38}-dj{20,21,22,30}-{sqlite,postgresql,mysql} [testenv] deps = dj111: Django==1.11.* + dj20: Django==2.0.* + dj21: Django==2.1.* dj22: Django==2.2.* dj30: Django==3.0.* sqlite: mock postgresql: psycopg2-binary - mariadb: mysqlclient - djmaster: https://github.com/django/django/archive/master.tar.gz + mysql: mysqlclient coverage Jinja2 html5lib selenium<4.0 sqlparse +passenv= + CI + DB_BACKEND + DB_NAME + DB_USER + DB_PASSWORD + DB_HOST + DB_PORT + GITHUB_* setenv = PYTHONPATH = {toxinidir} - postgresql: DJANGO_DATABASE_ENGINE = postgresql - mariadb: DJANGO_DATABASE_ENGINE = mysql + PYTHONWARNINGS = d + py38-dj31-postgresql: DJANGO_SELENIUM_TESTS = true + DB_NAME = {env:DB_NAME:debug_toolbar} + DB_USER = {env:DB_USER:debug_toolbar} + DB_HOST = {env:DB_HOST:localhost} + DB_PASSWORD = {env:DB_PASSWORD:debug_toolbar} whitelist_externals = make pip_pre = True commands = make coverage TEST_ARGS='{posargs:tests}' -[testenv:flake8] -basepython = python3 -commands = make flake8 -deps = flake8 -skip_install = true +[testenv:py{35,36,37,38}-dj{111,20,21,22,30}-postgresql] +setenv = + {[testenv]setenv} + DB_BACKEND = postgresql + DB_PORT = {env:DB_PORT:5432} + +[testenv:py{35,36,37,38}-dj{111,20,21,22,30}-mysql] +setenv = + {[testenv]setenv} + DB_BACKEND = mysql + DB_PORT = {env:DB_PORT:3306} + +[testenv:py{35,36,37,38}-dj{111,20,21,22,30}-sqlite] +setenv = + {[testenv]setenv} + DB_BACKEND = sqlite3 + DB_NAME = ":memory:" [testenv:style] basepython = python3 commands = make style_check deps = - black + black>=19.10b0 flake8 - isort -skip_install = true - -[testenv:jshint] -basepython = python3 -commands = make jshint + isort>=5.0.2 skip_install = true [testenv:readme] @@ -57,3 +74,16 @@ basepython = python3 commands = python setup.py check -r -s deps = readme_renderer skip_install = true + +[gh-actions] +python = + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38 + +[gh-actions:env] +DB_BACKEND = + mysql: mysql + postgresql: postgresql + sqlite3: sqlite