From bc08f692742981b4155818639d6f40df76d1cee5 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 v1.11.x branch Pin requirements to work with python2.7. Style corrections to get tox to pass. Pinning requirements for tox. Setup github test action for v1.X branch Convert to current tox file. Update requirements. Django requires session middleware now. Remove python3.4 from github actions. It's not supported. Remove docs from tox. Support PostgresJSON test. Format code according to black and isort. 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. Force the values to a string for signing. Remove hashing mechanism from forms. * Bump to version 1.11.1 --- .github/workflows/test.yml | 210 ++++++++++++++++++++++++ .travis.yml | 59 ------- Makefile | 11 +- README.rst | 2 +- debug_toolbar/decorators.py | 20 ++- debug_toolbar/forms.py | 55 +++++++ 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 | 7 + docs/conf.py | 164 +++++++++--------- requirements_dev.txt | 4 +- setup.py | 2 +- tests/commands/test_debugsqlshell.py | 7 + tests/models.py | 12 ++ tests/panels/test_sql.py | 7 + tests/settings.py | 22 +-- tests/test_forms.py | 50 ++++++ tests/test_integration.py | 83 ++++++++-- tox.ini | 112 ++++++------- 26 files changed, 827 insertions(+), 287 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.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..8905e83e5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,210 @@ +name: Test + +on: [push, pull_request] + +jobs: + mysql: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['2.7', '3.5', '3.6', '3.7'] + + 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: ['2.7', '3.5', '3.6', '3.7'] + + 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: ['2.7', '3.5', '3.6', '3.7'] + + 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 + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4fe8630bc..000000000 --- a/.travis.yml +++ /dev/null @@ -1,59 +0,0 @@ -dist: xenial -sudo: false -language: python -cache: pip -matrix: - fast_finish: true - include: - - python: 2.7 - env: TOXENV=py27-dj111 - - python: 3.4 - env: TOXENV=py34-dj111 - - python: 3.5 - env: TOXENV=py35-dj111 - - python: 3.6 - env: TOXENV=py36-dj111 - - python: 3.4 - env: TOXENV=py34-dj20 - - python: 3.5 - env: TOXENV=py35-dj20 - - python: 3.6 - env: TOXENV=py36-dj20 - - python: 3.7 - env: TOXENV=py37-dj20 - - python: 3.5 - env: TOXENV=py35-dj21 - - python: 3.6 - env: TOXENV=py36-dj21 - - python: 3.7 - env: TOXENV=py37-dj21 - - python: 3.5 - env: TOXENV=py35-djmaster - - python: 3.6 - env: TOXENV=py36-djmaster - - python: 3.7 - env: TOXENV=py37-djmaster - - python: 3.7 - env: TOXENV=postgresql - addons: - postgresql: "9.5" - - python: 3.7 - env: TOXENV=mariadb - addons: - mariadb: "10.3" - - env: TOXENV=flake8 - - python: 3.7 - env: TOXENV=style - - python: 3.7 - env: TOXENV=readme - allow_failures: - - env: TOXENV=py35-djmaster - - env: TOXENV=py36-djmaster - - env: TOXENV=py37-djmaster - -install: - - pip install tox codecov -script: - - tox -v -after_success: - - codecov diff --git a/Makefile b/Makefile index 071272179..95892788e 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ .PHONY: flake8 example test coverage translatable_strings update_translations style: - isort -rc debug_toolbar example tests - black debug_toolbar example tests setup.py - flake8 debug_toolbar example tests + isort . + black --target-version=py27 . + flake8 style_check: - isort -rc -c debug_toolbar example tests - black --check debug_toolbar example tests setup.py + isort -c . + black --target-version=py27 --check . + flake8 flake8: flake8 debug_toolbar example tests diff --git a/README.rst b/README.rst index 816d0be28..3803b4cc4 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 version of the Debug Toolbar is 1.11. It works on Django ≥ 1.11. +The current version of the Debug Toolbar is 1.11.1. It works on Django ≥ 1.11. Documentation, including installation and configuration instructions, is available at https://django-debug-toolbar.readthedocs.io/. 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..3fe0cd98c --- /dev/null +++ b/debug_toolbar/forms.py @@ -0,0 +1,55 @@ +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(SignedDataForm, self).__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): + # Sort the data by the keys to create a fixed ordering. + 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 c8030a585..9a3044c94 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -218,20 +218,26 @@ def _store_call_info( @property def nav_subtitle(self): cache_calls = len(self.calls) - return ungettext( - "%(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 ( + ungettext( + "%(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 ungettext( - "Cache calls from %(count)d backend", - "Cache calls from %(count)d backends", - count, - ) % dict(count=count) + return ( + ungettext( + "Cache calls from %(count)d backend", + "Cache calls from %(count)d backends", + count, + ) + % dict(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 80796a4f4..c426dfe2f 100644 --- a/debug_toolbar/panels/signals.py +++ b/debug_toolbar/panels/signals.py @@ -45,16 +45,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 ungettext( - "%(num_receivers)d receiver of 1 signal", - "%(num_receivers)d receivers of 1 signal", + return ( + ungettext( + "%(num_receivers)d receiver of 1 signal", + "%(num_receivers)d receivers of 1 signal", + num_receivers, + ) + % {"num_receivers": num_receivers} + ) + return ( + ungettext( + "%(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 ungettext( - "%(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 6cc1554a1..e3c7fd930 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -1,15 +1,10 @@ from __future__ import absolute_import, unicode_literals -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 @@ -23,7 +18,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() @@ -31,14 +25,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(SQLSelectForm, self).__init__(*args, **kwargs) for name in self.fields: @@ -68,23 +56,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"]) - 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 fac9c473b..d988c667e 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -9,6 +9,7 @@ from django.db import connections from django.utils.translation import ugettext_lazy as _, ungettext_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 @@ -119,11 +120,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" @@ -210,7 +214,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"]) diff --git a/debug_toolbar/panels/sql/views.py b/debug_toolbar/panels/sql/views.py index 4f17421c0..07731d1c6 100644 --- a/debug_toolbar/panels/sql/views.py +++ b/debug_toolbar/panels/sql/views.py @@ -4,15 +4,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"] @@ -36,9 +37,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"] @@ -73,9 +75,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 338a7acf2..53f13d44e 100644 --- a/debug_toolbar/panels/templates/views.py +++ b/debug_toolbar/panels/templates/views.py @@ -50,8 +50,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 8b933eb91..e2fa07313 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,13 @@ Change log UNRELEASED ---------- +1.11.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. + 1.11 (2018-12-03) ----------------- diff --git a/docs/conf.py b/docs/conf.py index 7e36bd8f0..e66104b43 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,45 +13,45 @@ # serve to show the default. import datetime -import sys import os +import sys -os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' +os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings" sys.path.append(os.path.dirname(os.path.dirname(__file__))) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Django Debug Toolbar' -copyright = u'{}, Django Debug Toolbar developers and contributors' +project = "Django Debug Toolbar" +copyright = "{}, Django Debug Toolbar developers and contributors" copyright = copyright.format(datetime.date.today().year) # The version info for the project you're documenting, acts as replacement for @@ -59,174 +59,177 @@ # built documents. # # The short X.Y version. -version = '1.11' +version = "1.11.1" # The full version, including alpha/beta/rc tags. -release = '1.11' +release = "1.11.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoDebugToolbardoc' +htmlhelp_basename = "DjangoDebugToolbardoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'DjangoDebugToolbar.tex', u'Django Debug Toolbar Documentation', - u'Django Debug Toolbar developers and contributors', 'manual'), + ( + "index", + "DjangoDebugToolbar.tex", + "Django Debug Toolbar Documentation", + "Django Debug Toolbar developers and contributors", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -234,12 +237,17 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'djangodebugtoolbar', u'Django Debug Toolbar Documentation', - [u'Django Debug Toolbar developers and contributors'], 1) + ( + "index", + "djangodebugtoolbar", + "Django Debug Toolbar Documentation", + ["Django Debug Toolbar developers and contributors"], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -248,28 +256,34 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'DjangoDebugToolbar', u'Django Debug Toolbar Documentation', - u'Django Debug Toolbar developers and contributors', 'DjangoDebugToolbar', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "DjangoDebugToolbar", + "Django Debug Toolbar Documentation", + "Django Debug Toolbar developers and contributors", + "DjangoDebugToolbar", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - 'https://docs.python.org/': None, - 'https://docs.djangoproject.com/en/dev/': 'https://docs.djangoproject.com/en/dev/_objects/', + "https://docs.python.org/": None, + "https://docs.djangoproject.com/en/dev/": "https://docs.djangoproject.com/en/dev/_objects/", } # -- Options for Read the Docs -------------------------------------------- diff --git a/requirements_dev.txt b/requirements_dev.txt index cc3c9afcd..caec41a57 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,8 +1,8 @@ # Runtime dependencies -Django +Django<3 sqlparse -django_jinja +django_jinja==2.4.1 # Testing diff --git a/setup.py b/setup.py index f7d6153f1..709f4c382 100755 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ def readall(path): setup( name="django-debug-toolbar", - version="1.11", + version="1.11.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 bb9ed50cb..314d02f24 100644 --- a/tests/commands/test_debugsqlshell.py +++ b/tests/commands/test_debugsqlshell.py @@ -1,9 +1,12 @@ from __future__ import absolute_import, unicode_literals import sys +import unittest +import django from django.contrib.auth.models import User from django.core import management +from django.db import connection from django.db.backends import utils as db_backends_utils from django.test import TestCase from django.test.utils import override_settings @@ -11,6 +14,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_cursor_wrapper = db_backends_utils.CursorDebugWrapper diff --git a/tests/models.py b/tests/models.py index ed6dbc1bd..5db11d8f2 100644 --- a/tests/models.py +++ b/tests/models.py @@ -13,3 +13,15 @@ def __repr__(self): class Binary(models.Model): field = models.BinaryField() + + +try: + from django.contrib.postgres.fields import JSONField +except ImportError: # psycopg2 not installed + JSONField = None + + +if JSONField: + + class PostgresJSON(models.Model): + field = JSONField() diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 81fd94e61..7b15d5dba 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -3,8 +3,10 @@ from __future__ import absolute_import, unicode_literals import datetime +import sys import unittest +import django from django.contrib.auth.models import User from django.db import connection from django.db.models import Count @@ -113,6 +115,10 @@ def test_param_conversion(self): ('["Foo", true, false]', "[10, 1]", '["2017-12-22 16:07:01"]'), ) + @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) @@ -136,6 +142,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 7bdc3ade8..4434870e2 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -80,17 +80,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..743996150 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from django import forms +from django.test import TestCase + +from debug_toolbar.forms import SignedDataForm + +SIGNATURE = "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 e440f6ae0..0671ca81e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,14 +5,17 @@ import os import unittest +import django import html5lib from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.core import signing from django.core.checks import Warning, run_checks +from django.db import connection from django.template.loader import get_template from django.test import RequestFactory, 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 @@ -171,12 +174,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) @@ -194,12 +200,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) @@ -214,15 +223,50 @@ def test_sql_explain_checks_show_toolbar(self): ) self.assertEqual(response.status_code, 404) + @unittest.skipUnless( + connection.vendor == "postgresql", "Test valid only on PostgreSQL" + ) + def test_sql_explain_postgres_json_field(self): + url = "/__debug__/sql_explain/" + base_query = ( + 'SELECT * FROM "tests_postgresjson" WHERE "tests_postgresjson"."field" @>' + ) + query = base_query + """ '{"foo": "bar"}'""" + data = { + "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) + response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 200) + with self.settings(INTERNAL_IPS=[]): + response = self.client.post(url, data) + self.assertEqual(response.status_code, 404) + response = self.client.post( + url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + self.assertEqual(response.status_code, 404) + 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) @@ -363,6 +407,7 @@ def test_django_cached_template_loader(self): class DebugToolbarSystemChecksTestCase(BaseTestCase): @override_settings( MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.gzip.GZipMiddleware", @@ -373,6 +418,7 @@ def test_check_good_configuration(self): messages = run_checks() self.assertEqual(messages, []) + @unittest.skipIf(django.VERSION >= (2, 2), "Django handles missing dirs itself.") @override_settings( MIDDLEWARE=[ "django.contrib.messages.middleware.MessageMiddleware", @@ -396,6 +442,7 @@ def test_check_missing_middleware_error(self): @override_settings( MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", diff --git a/tox.ini b/tox.ini index d14b7f71a..005372c55 100644 --- a/tox.ini +++ b/tox.ini @@ -1,86 +1,88 @@ [tox] envlist = - py{27,34,35,36}-dj111 - py{34,35,36,37}-dj20 - py{35,36,37}-dj21 - py{35,36,37}-djmaster - postgresql, - mariadb, - flake8, - style, + style readme + py{27,34,35,36}-dj111-{sqlite,postgresql,mysql} + py{34,35,36,37}-dj20-{sqlite,postgresql,mysql} + py{35,36,37}-dj21-{sqlite,postgresql,mysql} + py{35,36,37}-dj22-{sqlite,postgresql,mysql} [testenv] deps = - dj111: Django>=1.11,<2.0 - dj20: Django>=2.0,<2.1 - dj21: Django>=2.1,<2.2 - djmaster: https://github.com/django/django/archive/master.tar.gz + dj111: Django==1.11.* + dj20: Django==2.0.* + dj21: Django==2.1.* + dj22: Django==2.2.* + sqlite: mock + postgresql: psycopg2-binary + mysql: mysqlclient coverage - django_jinja + django_jinja==2.4.1 html5lib selenium<4.0 sqlparse +passenv= + CI + DB_BACKEND + DB_NAME + DB_USER + DB_PASSWORD + DB_HOST + DB_PORT + GITHUB_* setenv = PYTHONPATH = {toxinidir} + 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:postgresql] -deps = - Django>=2.1,<2.2 - coverage - django_jinja - html5lib - psycopg2-binary - selenium<4.0 - sqlparse +[testenv:py{27,34,35,36,37}-dj{111,20,21,22}-postgresql] setenv = - PYTHONPATH = {toxinidir} - DJANGO_DATABASE_ENGINE = postgresql -whitelist_externals = make -pip_pre = True -commands = make coverage TEST_ARGS='{posargs:tests}' + {[testenv]setenv} + DB_BACKEND = postgresql + DB_PORT = {env:DB_PORT:5432} -[testenv:mariadb] -deps = - Django>=2.1,<2.2 - coverage - django_jinja - html5lib - mysqlclient - selenium<4.0 - sqlparse +[testenv:py{27,34,35,36,37}-dj{111,20,21,22}-mysql] setenv = - PYTHONPATH = {toxinidir} - DJANGO_DATABASE_ENGINE = mysql -whitelist_externals = make -pip_pre = True -commands = make coverage TEST_ARGS='{posargs:tests}' + {[testenv]setenv} + DB_BACKEND = mysql + DB_PORT = {env:DB_PORT:3306} -[testenv:flake8] -basepython = python3 -commands = make flake8 -deps = flake8 -skip_install = true +[testenv:py{27,34,35,36,37}-dj{111,20,21,22}-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] -basepython = python3 commands = python setup.py check -r -s deps = readme_renderer skip_install = true + +[gh-actions] +python = + 2.7: py27 + 3.4: py34 + 3.5: py35 + 3.6: py36 + 3.7: py37 + +[gh-actions:env] +DB_BACKEND = + mysql: mysql + postgresql: postgresql + sqlite3: sqlite