Skip to content

Add .prof File Download Support to Profiling Panel #2146

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cProfile
import os
import tempfile
from colorsys import hsv_to_rgb
from pstats import Stats

Expand Down Expand Up @@ -168,8 +169,13 @@ def generate_stats(self, request, response):
self.stats = Stats(self.profiler)
self.stats.calc_callees()

root_func = cProfile.label(super().process_request.__code__)
prof_file_path = os.path.join(
tempfile.gettempdir(), next(tempfile._get_candidate_names()) + ".prof"
Copy link
Member

Choose a reason for hiding this comment

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

_get_candidate_names() isn't documented? I think we should only enable this feature when users provide a path where we can store profiles, because we have to ensure we're only serving files from this very path and not arbitrary files.

)
self.profiler.dump_stats(prof_file_path)
self.prof_file_path = prof_file_path

root_func = cProfile.label(super().process_request.__code__)
if root_func in self.stats.stats:
root = FunctionCall(self.stats, root_func, depth=0)
func_list = []
Expand All @@ -182,4 +188,6 @@ def generate_stats(self, request, response):
dt_settings.get_config()["PROFILER_MAX_DEPTH"],
cum_time_threshold,
)
self.record_stats({"func_list": func_list})
self.record_stats(
{"func_list": func_list, "prof_file_path": self.prof_file_path}
)
15 changes: 14 additions & 1 deletion debug_toolbar/panels/sql/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,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
Copy link
Member

Choose a reason for hiding this comment

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

This looks like an unrelated change?

finally:
self.db._djdt_logger = self.logger

Expand Down
9 changes: 9 additions & 0 deletions debug_toolbar/templates/debug_toolbar/panels/profiling.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{% load i18n %}

{% if prof_file_path %}
<div style="margin-bottom: 10px;">
Copy link
Member

Choose a reason for hiding this comment

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

We do not have any inline styles in templates until now, if we need styles add them to the toolbar.css file.

<a href="{% url 'debug_toolbar_download_prof_file' %}?path={{ prof_file_path|urlencode }}" class="djDebugButton">
Copy link
Member

Choose a reason for hiding this comment

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

The toolbar uses the djdt: namespace for URLs; search the code base for reverse( calls to see how that can be used. (See git grep -C3 'reverse(')

Download .prof file
</a>
</div>
{% endif %}

<table>
<thead>
<tr>
Expand Down
13 changes: 11 additions & 2 deletions debug_toolbar/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
from debug_toolbar import APP_NAME
from django.urls import path

from debug_toolbar import APP_NAME, views as debug_toolbar_views
from debug_toolbar.toolbar import DebugToolbar

app_name = APP_NAME
urlpatterns = DebugToolbar.get_urls()

urlpatterns = DebugToolbar.get_urls() + [
path(
"download_prof_file/",
debug_toolbar_views.download_prof_file,
name="debug_toolbar_download_prof_file",
),
]
22 changes: 21 additions & 1 deletion debug_toolbar/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from django.http import JsonResponse
import os

from django.http import FileResponse, Http404, 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._compat import login_not_required
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
Expand All @@ -25,3 +28,20 @@ def render_panel(request):
content = panel.content
scripts = panel.scripts
return JsonResponse({"content": content, "scripts": scripts})


@require_GET
def download_prof_file(request):
file_path = request.GET.get("path")
print("Serving .prof file:", file_path)
if not file_path or not os.path.exists(file_path):
print("File does not exist:", file_path)
Copy link
Member

Choose a reason for hiding this comment

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

This can trivially be exploited by using something like

...?path=/etc/passwd

This view serves arbitrary files which are readable by the webserver process: Either you have to use signing (django.core.signing, also used in the templates panel) to ensure that paths cannot be tampered with and/or use a known root where you put files, and ensure that you only serve files from this root. This isn't totally straightforward because you have to protect against path traversal attacks.

raise Http404("File not found.")

response = FileResponse(
open(file_path, "rb"), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
f'attachment; filename="{os.path.basename(file_path)}"'
)
return response
Loading