Skip to content

Commit 09f2ba0

Browse files
address problems
1 parent 3896fe3 commit 09f2ba0

3 files changed

Lines changed: 85 additions & 23 deletions

File tree

debug_toolbar/panels/profiling.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import cProfile
2+
import logging
23
import os
34
import uuid
45
from colorsys import hsv_to_rgb
@@ -12,6 +13,8 @@
1213
from debug_toolbar import settings as dt_settings
1314
from debug_toolbar.panels import Panel
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
class FunctionCall:
1720
def __init__(
@@ -186,14 +189,24 @@ def generate_stats(self, request, response):
186189
self.stats.calc_callees()
187190

188191
if (
189-
root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
190-
) and os.path.exists(root):
192+
profile_root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
193+
) and os.path.exists(profile_root):
191194
filename = f"{uuid.uuid4().hex}.prof"
192-
prof_file_path = os.path.join(root, filename)
193-
self.profiler.dump_stats(prof_file_path)
194-
self.prof_file_path = signing.dumps(filename)
195-
196-
root_func = cProfile.label(super().process_request.__code__)
195+
prof_file_path = os.path.join(profile_root, filename)
196+
try:
197+
self.profiler.dump_stats(prof_file_path)
198+
self.prof_file_path = signing.dumps(filename)
199+
except OSError:
200+
logger.error(
201+
"Failed to dump profiling stats to %s",
202+
prof_file_path,
203+
exc_info=True,
204+
)
205+
# If writing to the file fails, we don't want to break the
206+
# whole page.
207+
208+
code = super().process_request.__code__
209+
root_func = (code.co_filename, code.co_firstlineno, code.co_name)
197210
if root_func in self.stats.stats:
198211
root = FunctionCall(self.stats, root_func, depth=0)
199212
func_list = []

debug_toolbar/views.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def render_panel(request: HttpRequest) -> JsonResponse:
3636

3737

3838
@require_GET
39+
@login_not_required
3940
def download_prof_file(request):
4041
if not (root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]):
4142
raise Http404()
@@ -48,12 +49,14 @@ def download_prof_file(request):
4849
except signing.BadSignature:
4950
raise Http404() from None
5051

51-
resolved_path = pathlib.Path(root) / filename
52-
if not resolved_path.exists():
52+
root_path = pathlib.Path(root).resolve()
53+
resolved_path = (root_path / filename).resolve()
54+
if not resolved_path.is_relative_to(root_path) or not resolved_path.exists():
5355
raise Http404()
5456

55-
response = FileResponse(
56-
open(resolved_path, "rb"), content_type="application/octet-stream"
57+
return FileResponse(
58+
open(resolved_path, "rb"),
59+
as_attachment=True,
60+
filename=resolved_path.name,
61+
content_type="application/octet-stream",
5762
)
58-
response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"'
59-
return response

tests/panels/test_profiling.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sys
44
import tempfile
55
import unittest
6+
from unittest import mock
67

78
from django.contrib.auth.models import User
89
from django.core import signing
@@ -83,17 +84,16 @@ def test_generate_stats_no_profiler(self):
8384
response = HttpResponse()
8485
self.assertIsNone(self.panel.generate_stats(self.request, response))
8586

86-
@override_settings(
87-
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tempfile.gettempdir()}
88-
)
8987
def test_generate_stats_signed_path(self):
90-
response = self.panel.process_request(self.request)
91-
self.panel.generate_stats(self.request, response)
92-
path = self.panel.prof_file_path
93-
self.assertTrue(path)
94-
# Check that it's a valid signature
95-
filename = signing.loads(path)
96-
self.assertTrue(filename.endswith(".prof"))
88+
with tempfile.TemporaryDirectory() as tmpdir:
89+
with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}):
90+
response = self.panel.process_request(self.request)
91+
self.panel.generate_stats(self.request, response)
92+
path = self.panel.prof_file_path
93+
self.assertTrue(path)
94+
# Check that it's a valid signature
95+
filename = signing.loads(path)
96+
self.assertTrue(filename.endswith(".prof"))
9797

9898
def test_generate_stats_no_root(self):
9999
response = self.panel.process_request(self.request)
@@ -112,6 +112,20 @@ def test_generate_stats_no_root_func(self):
112112
self.panel.generate_stats(self.request, response)
113113
self.assertNotIn("func_list", self.panel.get_stats())
114114

115+
@mock.patch("cProfile.Profile.dump_stats")
116+
def test_generate_stats_oserror(self, mock_dump_stats):
117+
mock_dump_stats.side_effect = OSError
118+
with tempfile.TemporaryDirectory() as tmpdir:
119+
with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}):
120+
response = self.panel.process_request(self.request)
121+
with self.assertLogs(
122+
"debug_toolbar.panels.profiling", level="ERROR"
123+
) as cm:
124+
self.panel.generate_stats(self.request, response)
125+
self.assertIn("Failed to dump profiling stats", cm.output[0])
126+
# Ensure prof_file_path is not set/updated if dump fails
127+
self.assertFalse(hasattr(self.panel, "prof_file_path"))
128+
115129

116130
@override_settings(
117131
DEBUG=True, DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.profiling.ProfilingPanel"]
@@ -172,3 +186,35 @@ def test_download_missing_file(self):
172186
path = signing.dumps("missing.prof")
173187
response = self.client.get(url, {"path": path})
174188
self.assertEqual(response.status_code, 404)
189+
190+
def test_download_path_traversal(self):
191+
with override_settings(
192+
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
193+
):
194+
url = reverse("djdt:debug_toolbar_download_prof_file")
195+
# Sign a filename that traverses safely out of the root
196+
path = signing.dumps("../passwd")
197+
response = self.client.get(url, {"path": path})
198+
self.assertEqual(response.status_code, 404)
199+
200+
def test_download_absolute_path(self):
201+
with override_settings(
202+
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
203+
):
204+
url = reverse("djdt:debug_toolbar_download_prof_file")
205+
# Create a file outside the root and try to access it via absolute path
206+
with tempfile.NamedTemporaryFile() as tmp:
207+
path = signing.dumps(tmp.name)
208+
response = self.client.get(url, {"path": path})
209+
self.assertEqual(response.status_code, 404)
210+
211+
def test_download_recursive_traversal(self):
212+
with override_settings(
213+
DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
214+
):
215+
url = reverse("djdt:debug_toolbar_download_prof_file")
216+
# Try a convoluted path that resolves outside
217+
# e.g. root/subdir/../../outside_root
218+
path = signing.dumps(os.path.join("subdir", "..", "..", "passwd"))
219+
response = self.client.get(url, {"path": path})
220+
self.assertEqual(response.status_code, 404)

0 commit comments

Comments
 (0)