Skip to content

Conversation

@JohananOppongAmoateng
Copy link
Contributor

@JohananOppongAmoateng JohananOppongAmoateng commented Dec 17, 2025

Description

This pr is based on the work of @andoriyaprashant which he made to fix #1798. I am continuing his work to get it over the finish line. His pr is this #2146 .

Fixes #1798

Checklist:

  • I have added the relevant tests for this change.
  • I have added an item to the Pending section of docs/changes.rst.

@github-actions
Copy link

github-actions bot commented Dec 17, 2025

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  debug_toolbar
  toolbar.py
  urls.py
  views.py 44
  debug_toolbar/panels
  profiling.py
  debug_toolbar/panels/sql
  tracking.py 161-162
Project Total  

This report was generated by python-coverage-comment-action

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds the ability to download profiling data as .prof files, addressing issue #1798. The feature is disabled by default and requires configuring the PROFILER_PROFILE_ROOT setting to specify where profile files should be saved.

Key changes:

  • Added PROFILER_PROFILE_ROOT configuration setting to control where profile files are saved
  • Implemented profile file generation in the profiling panel with signed filenames for security
  • Created a new download view with URL endpoint to serve the profile files

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
debug_toolbar/settings.py Added PROFILER_PROFILE_ROOT default setting (None)
debug_toolbar/panels/profiling.py Modified to generate and save .prof files when PROFILER_PROFILE_ROOT is configured; signs filename for secure download
debug_toolbar/views.py Added download_prof_file view to handle file downloads with signature validation
debug_toolbar/toolbar.py Registered new download URL endpoint
debug_toolbar/urls.py Minor whitespace addition
debug_toolbar/templates/debug_toolbar/panels/profiling.html Added download link UI and reformatted indentation
debug_toolbar/static/debug_toolbar/css/toolbar.css Added styling for download control
tests/panels/test_profiling.py Added comprehensive tests for profile file generation and download functionality
docs/configuration.rst Documented the new PROFILER_PROFILE_ROOT setting
docs/changes.rst Added changelog entry for the new feature
debug_toolbar/panels/sql/tracking.py Unrelated changes to handle executemany parameters in SQL tracking

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +86 to +96
@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"))
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test cleanup issue: The test_generate_stats_signed_path creates a .prof file in tempfile.gettempdir() but doesn't clean it up after the test. Consider adding cleanup in a tearDown method or using a temporary directory that gets cleaned up automatically (similar to ProfilingDownloadViewTestCase which uses tempfile.mkdtemp and shutil.rmtree).

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +52
resolved_path = pathlib.Path(root) / filename
if not resolved_path.exists():
Copy link

Copilot AI Dec 17, 2025

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.

Suggested change
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 uses AI. Check for mistakes.
raise Http404()

response = FileResponse(
open(resolved_path, "rb"), content_type="application/octet-stream"
Copy link

Copilot AI Dec 17, 2025

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.

Suggested change
open(resolved_path, "rb"), content_type="application/octet-stream"
resolved_path, content_type="application/octet-stream"

Copilot uses AI. Check for mistakes.
Comment on lines +188 to 198
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)
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable name collision: The variable 'root' on line 189 shadows the 'root' variable used on line 198 for the FunctionCall object. This makes the code confusing to read. Consider renaming the root directory variable to something like 'profile_root' or 'root_dir' to avoid confusion.

Copilot uses AI. Check for mistakes.
path(
"download_prof_file/",
views.download_prof_file,
name="debug_toolbar_download_prof_file",
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL name inconsistency: The URL name "debug_toolbar_download_prof_file" is inconsistent with the naming convention used by other URLs in this codebase. Other URLs use simple names without the "debug_toolbar_" prefix (e.g., "render_panel" on line 165, "history_sidebar", "sql_select", "template_source"). Consider renaming to just "download_prof_file" for consistency.

Suggested change
name="debug_toolbar_download_prof_file",
name="download_prof_file",

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +58
open(resolved_path, "rb"), content_type="application/octet-stream"
)
response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"'
Copy link

Copilot AI Dec 17, 2025

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.

Suggested change
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",
)

Copilot uses AI. Check for mistakes.
scripts = panel.scripts
return JsonResponse({"content": content, "scripts": scripts})


Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download_prof_file view is missing authentication and authorization decorators. The render_panel view on line 16-18 uses @login_not_required, @require_show_toolbar, and @render_with_toolbar_language decorators. At minimum, this view should have @login_not_required and @require_show_toolbar decorators to ensure only authorized users can download profile files.

Suggested change
@login_not_required
@require_show_toolbar

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +162
# 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
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes to handle executemany parameters appear unrelated to the PR's stated purpose of adding profile file downloads. This change modifies SQL query logging behavior and should be in a separate PR. If this is intentionally included, the PR description should explain why these SQL tracking changes are necessary for the profiling download feature.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +174
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)
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test for path traversal attack: The test suite should include a test that verifies path traversal attempts (e.g., signing "../../../etc/passwd" or "subdir/../../../etc/passwd") are properly rejected and return 404. This is important for security validation.

Copilot uses AI. Check for mistakes.
Comment on lines +188 to +194
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)
Copy link

Copilot AI Dec 17, 2025

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Download profiling results

2 participants