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