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