Skip to content

Commit 68c4410

Browse files
committed
Prevent unauthorized access to views
All views in debug_toolbar are now decorated with the new debug_toolbar.decorators.require_show_toolbar. This prevents access when the function defined by SHOW_TOOLBAR_CALLBACK returns False. For example, when a request originated form outside INTERNAL_IPS. Fixes #834
1 parent 84cb158 commit 68c4410

File tree

8 files changed

+159
-17
lines changed

8 files changed

+159
-17
lines changed

debug_toolbar/decorators.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import functools
2+
3+
from django.http import Http404
4+
5+
from debug_toolbar.middleware import get_show_toolbar
6+
7+
8+
def require_show_toolbar(view):
9+
@functools.wraps(view)
10+
def inner(request, *args, **kwargs):
11+
show_toolbar = get_show_toolbar()
12+
if not show_toolbar(request):
13+
raise Http404
14+
15+
return view(request, *args, **kwargs)
16+
return inner

debug_toolbar/middleware.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.conf import settings
1111
from django.utils import six
1212
from django.utils.encoding import force_text
13-
from django.utils.functional import cached_property
13+
from django.utils.lru_cache import lru_cache
1414
from django.utils.module_loading import import_string
1515

1616
from debug_toolbar import settings as dt_settings
@@ -33,32 +33,35 @@ def show_toolbar(request):
3333
if request.META.get('REMOTE_ADDR', None) not in settings.INTERNAL_IPS:
3434
return False
3535

36-
if request.is_ajax():
37-
return False
38-
3936
return bool(settings.DEBUG)
4037

4138

39+
@lru_cache()
40+
def get_show_toolbar():
41+
# If SHOW_TOOLBAR_CALLBACK is a string, which is the recommended
42+
# setup, resolve it to the corresponding callable.
43+
func_or_path = dt_settings.get_config()['SHOW_TOOLBAR_CALLBACK']
44+
if isinstance(func_or_path, six.string_types):
45+
return import_string(func_or_path)
46+
else:
47+
return func_or_path
48+
49+
4250
class DebugToolbarMiddleware(MiddlewareMixin):
4351
"""
4452
Middleware to set up Debug Toolbar on incoming request and render toolbar
4553
on outgoing response.
4654
"""
4755
debug_toolbars = {}
4856

49-
@cached_property
50-
def show_toolbar(self):
51-
# If SHOW_TOOLBAR_CALLBACK is a string, which is the recommended
52-
# setup, resolve it to the corresponding callable.
53-
func_or_path = dt_settings.get_config()['SHOW_TOOLBAR_CALLBACK']
54-
if isinstance(func_or_path, six.string_types):
55-
return import_string(func_or_path)
56-
else:
57-
return func_or_path
58-
5957
def process_request(self, request):
6058
# Decide whether the toolbar is active for this request.
61-
if not self.show_toolbar(request):
59+
show_toolbar = get_show_toolbar()
60+
if not show_toolbar(request):
61+
return
62+
63+
# Don't render the toolbar during AJAX requests.
64+
if request.is_ajax():
6265
return
6366

6467
toolbar = DebugToolbar(request)

debug_toolbar/panels/sql/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from django.shortcuts import render_to_response
55
from django.views.decorators.csrf import csrf_exempt
66

7+
from debug_toolbar.decorators import require_show_toolbar
78
from debug_toolbar.panels.sql.forms import SQLSelectForm
89

910

1011
@csrf_exempt
12+
@require_show_toolbar
1113
def sql_select(request):
1214
"""Returns the output of the SQL SELECT statement"""
1315
form = SQLSelectForm(request.POST or None)
@@ -33,6 +35,7 @@ def sql_select(request):
3335

3436

3537
@csrf_exempt
38+
@require_show_toolbar
3639
def sql_explain(request):
3740
"""Returns the output of the SQL EXPLAIN on the given query"""
3841
form = SQLSelectForm(request.POST or None)
@@ -69,6 +72,7 @@ def sql_explain(request):
6972

7073

7174
@csrf_exempt
75+
@require_show_toolbar
7276
def sql_profile(request):
7377
"""Returns the output of running the SQL and getting the profiling statistics"""
7478
form = SQLSelectForm(request.POST or None)

debug_toolbar/panels/templates/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
from django.template.engine import Engine
77
from django.utils.safestring import mark_safe
88

9+
from debug_toolbar.decorators import require_show_toolbar
10+
911
try:
1012
from django.template import Origin
1113
except ImportError:
1214
Origin = None
1315

1416

17+
@require_show_toolbar
1518
def template_source(request):
1619
"""
1720
Return the source of a template, syntax-highlighted by Pygments if

debug_toolbar/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from django.utils.html import escape
55
from django.utils.translation import ugettext as _
66

7+
from debug_toolbar.decorators import require_show_toolbar
78
from debug_toolbar.toolbar import DebugToolbar
89

910

11+
@require_show_toolbar
1012
def render_panel(request):
1113
"""Render the contents of a panel"""
1214
toolbar = DebugToolbar.fetch(request.GET['store_id'])

docs/changes.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ Change log
44
1.8 (upcoming)
55
--------------
66

7+
Features
8+
~~~~~~~~
9+
10+
* New decorator ``debug_toolbar.decorators.require_show_toolbar`` prevents
11+
unauthorized access to decorated views by checking ``SHOW_TOOLBAR_CALLBACK``
12+
every request. Unauthorized access results in a 404.
13+
14+
Bugfixes
15+
~~~~~~~~
16+
17+
* All views are now decorated with
18+
``debug_toolbar.decorators.require_show_toolbar`` preventing unauthorized
19+
access.
20+
721
1.7
822
---
923

docs/panels.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,9 @@ Third-party panels must subclass :class:`~debug_toolbar.panels.Panel`,
309309
according to the public API described below. Unless noted otherwise, all
310310
methods are optional.
311311

312-
Panels can ship their own templates, static files and views. There is no public
313-
CSS API at this time.
312+
Panels can ship their own templates, static files and views. All views should
313+
be decorated with ``debug_toolbar.decorators.require_show_toolbar`` to prevent
314+
unauthorized access. There is no public CSS API at this time.
314315

315316
.. autoclass:: debug_toolbar.panels.Panel(*args, **kwargs)
316317

tests/test_integration.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import django
1010
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
1111
from django.core.checks import Error, run_checks
12+
from django.template.loader import get_template
1213
from django.test import RequestFactory, TestCase
1314
from django.test.utils import override_settings
1415

1516
from debug_toolbar.middleware import DebugToolbarMiddleware, show_toolbar
17+
from debug_toolbar.toolbar import DebugToolbar
1618

1719
from .base import BaseTestCase
1820
from .views import regular_view
@@ -120,6 +122,103 @@ def test_xml_validation(self):
120122
response = self.client.get('/regular/XML/')
121123
ET.fromstring(response.content) # shouldn't raise ParseError
122124

125+
def test_render_panel_checks_show_toolbar(self):
126+
toolbar = DebugToolbar(None)
127+
toolbar.store()
128+
url = '/__debug__/render_panel/'
129+
data = {'store_id': toolbar.store_id, 'panel_id': 'VersionsPanel'}
130+
131+
response = self.client.get(url, data)
132+
self.assertEqual(response.status_code, 200)
133+
response = self.client.get(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
134+
self.assertEqual(response.status_code, 200)
135+
with self.settings(INTERNAL_IPS=[]):
136+
response = self.client.get(url, data)
137+
self.assertEqual(response.status_code, 404)
138+
response = self.client.get(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
139+
self.assertEqual(response.status_code, 404)
140+
141+
def test_template_source_checks_show_toolbar(self):
142+
template = get_template('basic.html')
143+
url = '/__debug__/template_source/'
144+
data = {
145+
'template': template.template.name,
146+
'template_origin': template.template.origin.name
147+
}
148+
149+
response = self.client.get(url, data)
150+
self.assertEqual(response.status_code, 200)
151+
response = self.client.get(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
152+
self.assertEqual(response.status_code, 200)
153+
with self.settings(INTERNAL_IPS=[]):
154+
response = self.client.get(url, data)
155+
self.assertEqual(response.status_code, 404)
156+
response = self.client.get(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
157+
self.assertEqual(response.status_code, 404)
158+
159+
def test_sql_select_checks_show_toolbar(self):
160+
url = '/__debug__/sql_select/'
161+
data = {
162+
'sql': 'SELECT * FROM auth_user',
163+
'raw_sql': 'SELECT * FROM auth_user',
164+
'params': '{}',
165+
'alias': 'default',
166+
'duration': '0',
167+
'hash': '6e12daa636b8c9a8be993307135458f90a877606',
168+
}
169+
170+
response = self.client.post(url, data)
171+
self.assertEqual(response.status_code, 200)
172+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
173+
self.assertEqual(response.status_code, 200)
174+
with self.settings(INTERNAL_IPS=[]):
175+
response = self.client.post(url, data)
176+
self.assertEqual(response.status_code, 404)
177+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
178+
self.assertEqual(response.status_code, 404)
179+
180+
def test_sql_explain_checks_show_toolbar(self):
181+
url = '/__debug__/sql_explain/'
182+
data = {
183+
'sql': 'SELECT * FROM auth_user',
184+
'raw_sql': 'SELECT * FROM auth_user',
185+
'params': '{}',
186+
'alias': 'default',
187+
'duration': '0',
188+
'hash': '6e12daa636b8c9a8be993307135458f90a877606',
189+
}
190+
191+
response = self.client.post(url, data)
192+
self.assertEqual(response.status_code, 200)
193+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
194+
self.assertEqual(response.status_code, 200)
195+
with self.settings(INTERNAL_IPS=[]):
196+
response = self.client.post(url, data)
197+
self.assertEqual(response.status_code, 404)
198+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
199+
self.assertEqual(response.status_code, 404)
200+
201+
def test_sql_profile_checks_show_toolbar(self):
202+
url = '/__debug__/sql_profile/'
203+
data = {
204+
'sql': 'SELECT * FROM auth_user',
205+
'raw_sql': 'SELECT * FROM auth_user',
206+
'params': '{}',
207+
'alias': 'default',
208+
'duration': '0',
209+
'hash': '6e12daa636b8c9a8be993307135458f90a877606',
210+
}
211+
212+
response = self.client.post(url, data)
213+
self.assertEqual(response.status_code, 200)
214+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
215+
self.assertEqual(response.status_code, 200)
216+
with self.settings(INTERNAL_IPS=[]):
217+
response = self.client.post(url, data)
218+
self.assertEqual(response.status_code, 404)
219+
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
220+
self.assertEqual(response.status_code, 404)
221+
123222

124223
@unittest.skipIf(webdriver is None, "selenium isn't installed")
125224
@unittest.skipUnless('DJANGO_SELENIUM_TESTS' in os.environ, "selenium tests not requested")

0 commit comments

Comments
 (0)