-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Download prof files #2286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Download prof files #2286
Changes from all commits
48ac250
49f29eb
7ec7f25
fe4e305
5db558d
74ddf5e
5bbb211
a285d82
2bd5cfe
e698cde
66e2d50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import cProfile | ||
| import os | ||
| import uuid | ||
| from colorsys import hsv_to_rgb | ||
| from pstats import Stats | ||
|
|
||
| from django.conf import settings | ||
| from django.core import signing | ||
| from django.utils.html import format_html | ||
| from django.utils.translation import gettext_lazy as _ | ||
|
|
||
|
|
@@ -183,8 +185,15 @@ def generate_stats(self, request, response): | |
| self.stats = Stats(self.profiler) | ||
| self.stats.calc_callees() | ||
|
|
||
| root_func = cProfile.label(super().process_request.__code__) | ||
| if ( | ||
| root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"] | ||
| ) and os.path.exists(root): | ||
| filename = f"{uuid.uuid4().hex}.prof" | ||
| prof_file_path = os.path.join(root, filename) | ||
| self.profiler.dump_stats(prof_file_path) | ||
| self.prof_file_path = signing.dumps(filename) | ||
|
|
||
| root_func = cProfile.label(super().process_request.__code__) | ||
| if root_func in self.stats.stats: | ||
| root = FunctionCall(self.stats, root_func, depth=0) | ||
|
Comment on lines
+188
to
198
|
||
| func_list = [] | ||
|
|
@@ -197,4 +206,9 @@ def generate_stats(self, request, response): | |
| dt_settings.get_config()["PROFILER_MAX_DEPTH"], | ||
| cum_time_threshold, | ||
| ) | ||
| self.record_stats({"func_list": [func.serialize() for func in func_list]}) | ||
| self.record_stats( | ||
| { | ||
| "func_list": [func.serialize() for func in func_list], | ||
| "prof_file_path": getattr(self, "prof_file_path", None), | ||
| } | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -146,7 +146,20 @@ def _last_executed_query(self, sql, params): | |
| # process during the .last_executed_query() call. | ||
| self.db._djdt_logger = None | ||
| try: | ||
| return self.db.ops.last_executed_query(self.cursor, sql, params) | ||
| # Handle executemany: take the first set of parameters for formatting | ||
| if ( | ||
| isinstance(params, (list, tuple)) | ||
| and len(params) > 0 | ||
| and isinstance(params[0], (list, tuple)) | ||
| ): | ||
| sample_params = params[0] | ||
| else: | ||
| sample_params = params | ||
|
|
||
| try: | ||
| return self.db.ops.last_executed_query(self.cursor, sql, sample_params) | ||
| except Exception: | ||
| return sql | ||
|
Comment on lines
+149
to
+162
|
||
| finally: | ||
| self.db._djdt_logger = self.logger | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -163,6 +163,11 @@ def get_urls(cls) -> list[URLPattern | URLResolver]: | |||||
| # Global URLs | ||||||
| urlpatterns = [ | ||||||
| path("render_panel/", views.render_panel, name="render_panel"), | ||||||
| path( | ||||||
| "download_prof_file/", | ||||||
| views.download_prof_file, | ||||||
| name="debug_toolbar_download_prof_file", | ||||||
|
||||||
| name="debug_toolbar_download_prof_file", | |
| name="download_prof_file", |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,4 +2,5 @@ | |
| from debug_toolbar.toolbar import DebugToolbar | ||
|
|
||
| app_name = APP_NAME | ||
|
|
||
| urlpatterns = DebugToolbar.get_urls() | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,12 @@ | ||||||||||||||||||
| from django.http import HttpRequest, JsonResponse | ||||||||||||||||||
| import pathlib | ||||||||||||||||||
|
|
||||||||||||||||||
| from django.core import signing | ||||||||||||||||||
| from django.http import FileResponse, Http404, HttpRequest, JsonResponse | ||||||||||||||||||
| from django.utils.html import escape | ||||||||||||||||||
| from django.utils.translation import gettext as _ | ||||||||||||||||||
| from django.views.decorators.http import require_GET | ||||||||||||||||||
|
|
||||||||||||||||||
| from debug_toolbar import settings as dt_settings | ||||||||||||||||||
| from debug_toolbar._compat import login_not_required | ||||||||||||||||||
| from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar | ||||||||||||||||||
| from debug_toolbar.panels import Panel | ||||||||||||||||||
|
|
@@ -28,3 +33,27 @@ def render_panel(request: HttpRequest) -> JsonResponse: | |||||||||||||||||
| content = panel.content | ||||||||||||||||||
| scripts = panel.scripts | ||||||||||||||||||
| return JsonResponse({"content": content, "scripts": scripts}) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
|
||||||||||||||||||
| @login_not_required | |
| @require_show_toolbar |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Path traversal vulnerability: The code does not validate that the resolved path stays within the configured root directory. An attacker could sign a filename like "../../../etc/passwd" to access files outside the intended directory. Add validation using resolved_path.resolve().is_relative_to(pathlib.Path(root).resolve()) to ensure the file path is within the allowed directory.
| resolved_path = pathlib.Path(root) / filename | |
| if not resolved_path.exists(): | |
| root_path = pathlib.Path(root).resolve() | |
| resolved_path = (root_path / filename).resolve() | |
| if not resolved_path.is_relative_to(root_path) or not resolved_path.exists(): |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resource leak: The file opened with open(resolved_path, "rb") is never explicitly closed. While FileResponse will eventually close it, it's better to use FileResponse with a path string or use a context manager to ensure proper resource cleanup. Consider using FileResponse(resolved_path, ...) which accepts a path-like object and handles file closing automatically.
| open(resolved_path, "rb"), content_type="application/octet-stream" | |
| resolved_path, content_type="application/octet-stream" |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential header injection vulnerability: The filename in the Content-Disposition header is not properly escaped. If a malicious filename contains quotes or newlines, it could lead to header injection attacks. Consider using Django's http.urlquote or properly escaping the filename value, or use Django's FileResponse which can handle this automatically when you pass the filename parameter instead of setting the header manually.
| open(resolved_path, "rb"), content_type="application/octet-stream" | |
| ) | |
| response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"' | |
| open(resolved_path, "rb"), | |
| as_attachment=True, | |
| filename=resolved_path.name, | |
| content_type="application/octet-stream", | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,16 @@ | ||
| import os | ||
| import shutil | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
|
|
||
| from django.contrib.auth.models import User | ||
| from django.core import signing | ||
| from django.db import IntegrityError, transaction | ||
| from django.http import HttpResponse | ||
| from django.test import TestCase | ||
| from django.test.utils import override_settings | ||
| from django.urls import reverse | ||
|
|
||
| from debug_toolbar.panels.profiling import ProfilingPanel | ||
|
|
||
|
|
@@ -77,6 +83,24 @@ def test_generate_stats_no_profiler(self): | |
| response = HttpResponse() | ||
| self.assertIsNone(self.panel.generate_stats(self.request, response)) | ||
|
|
||
| @override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tempfile.gettempdir()} | ||
| ) | ||
| def test_generate_stats_signed_path(self): | ||
| response = self.panel.process_request(self.request) | ||
| self.panel.generate_stats(self.request, response) | ||
| path = self.panel.prof_file_path | ||
| self.assertTrue(path) | ||
| # Check that it's a valid signature | ||
| filename = signing.loads(path) | ||
| self.assertTrue(filename.endswith(".prof")) | ||
|
Comment on lines
+86
to
+96
|
||
|
|
||
| def test_generate_stats_no_root(self): | ||
| response = self.panel.process_request(self.request) | ||
| self.panel.generate_stats(self.request, response) | ||
| # Should not have a path if root is not set | ||
| self.assertFalse(hasattr(self.panel, "prof_file_path")) | ||
|
|
||
| def test_generate_stats_no_root_func(self): | ||
| """ | ||
| Test generating stats using profiler without root function. | ||
|
|
@@ -103,3 +127,48 @@ def test_view_executed_once(self): | |
| with self.assertRaises(IntegrityError), transaction.atomic(): | ||
| response = self.client.get("/new_user/") | ||
| self.assertEqual(User.objects.count(), 1) | ||
|
|
||
|
|
||
| class ProfilingDownloadViewTestCase(TestCase): | ||
| def setUp(self): | ||
| self.root = tempfile.mkdtemp() | ||
| self.filename = "test.prof" | ||
| self.filepath = os.path.join(self.root, self.filename) | ||
| with open(self.filepath, "wb") as f: | ||
| f.write(b"data") | ||
| self.signed_path = signing.dumps(self.filename) | ||
|
|
||
| def tearDown(self): | ||
| shutil.rmtree(self.root) | ||
|
|
||
| def test_download_no_root_configured(self): | ||
| response = self.client.get(reverse("djdt:debug_toolbar_download_prof_file")) | ||
| self.assertEqual(response.status_code, 404) | ||
|
|
||
| def test_download_valid(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| response = self.client.get(url, {"path": self.signed_path}) | ||
| self.assertEqual(response.status_code, 200) | ||
| self.assertEqual(list(response.streaming_content), [b"data"]) | ||
|
|
||
| def test_download_invalid_signature(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| # Tamper with the signature | ||
| response = self.client.get(url, {"path": self.signed_path + "bad"}) | ||
| self.assertEqual(response.status_code, 404) | ||
|
|
||
| def test_download_missing_file(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| # Sign a filename that doesn't exist | ||
| path = signing.dumps("missing.prof") | ||
| response = self.client.get(url, {"path": path}) | ||
| self.assertEqual(response.status_code, 404) | ||
|
Comment on lines
+132
to
+174
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for profiler.dump_stats(): If the dump_stats call fails (e.g., due to disk full, permissions error, or directory deleted after existence check), the prof_file_path will not be set but no error will be logged or reported. Consider wrapping the dump_stats call in a try-except block to gracefully handle failures and log errors, so the profiling panel continues to work even if file saving fails.