Skip to content

Commit e6076b5

Browse files
Sanitize sensitive variables in RequestPanel (#2105)
1 parent 0924116 commit e6076b5

File tree

5 files changed

+181
-21
lines changed

5 files changed

+181
-21
lines changed

debug_toolbar/panels/request.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.utils.translation import gettext_lazy as _
44

55
from debug_toolbar.panels import Panel
6-
from debug_toolbar.utils import get_name_from_obj, get_sorted_request_variable
6+
from debug_toolbar.utils import get_name_from_obj, sanitize_and_sort_request_vars
77

88

99
class RequestPanel(Panel):
@@ -26,9 +26,9 @@ def nav_subtitle(self):
2626
def generate_stats(self, request, response):
2727
self.record_stats(
2828
{
29-
"get": get_sorted_request_variable(request.GET),
30-
"post": get_sorted_request_variable(request.POST),
31-
"cookies": get_sorted_request_variable(request.COOKIES),
29+
"get": sanitize_and_sort_request_vars(request.GET),
30+
"post": sanitize_and_sort_request_vars(request.POST),
31+
"cookies": sanitize_and_sort_request_vars(request.COOKIES),
3232
}
3333
)
3434

@@ -59,13 +59,5 @@ def generate_stats(self, request, response):
5959
self.record_stats(view_info)
6060

6161
if hasattr(request, "session"):
62-
try:
63-
session_list = [
64-
(k, request.session.get(k)) for k in sorted(request.session.keys())
65-
]
66-
except TypeError:
67-
session_list = [
68-
(k, request.session.get(k))
69-
for k in request.session.keys() # (it's not a dict)
70-
]
71-
self.record_stats({"session": {"list": session_list}})
62+
session_data = dict(request.session)
63+
self.record_stats({"session": sanitize_and_sort_request_vars(session_data)})

debug_toolbar/utils.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
from django.template import Node
1515
from django.utils.html import format_html
1616
from django.utils.safestring import SafeString, mark_safe
17+
from django.views.debug import get_default_exception_reporter_filter
1718

1819
from debug_toolbar import _stubs as stubs, settings as dt_settings
1920

2021
_local_data = Local()
22+
safe_filter = get_default_exception_reporter_filter()
2123

2224

2325
def _is_excluded_frame(frame: Any, excluded_modules: Sequence[str] | None) -> bool:
@@ -215,20 +217,50 @@ def getframeinfo(frame: Any, context: int = 1) -> inspect.Traceback:
215217
return inspect.Traceback(filename, lineno, frame.f_code.co_name, lines, index)
216218

217219

218-
def get_sorted_request_variable(
220+
def sanitize_and_sort_request_vars(
219221
variable: dict[str, Any] | QueryDict,
220222
) -> dict[str, list[tuple[str, Any]] | Any]:
221223
"""
222224
Get a data structure for showing a sorted list of variables from the
223-
request data.
225+
request data with sensitive values redacted.
224226
"""
227+
if not isinstance(variable, (dict, QueryDict)):
228+
return {"raw": variable}
229+
230+
# Get sorted keys if possible, otherwise just list them
231+
keys = _get_sorted_keys(variable)
232+
233+
# Process the variable based on its type
234+
if isinstance(variable, QueryDict):
235+
result = _process_query_dict(variable, keys)
236+
else:
237+
result = _process_dict(variable, keys)
238+
239+
return {"list": result}
240+
241+
242+
def _get_sorted_keys(variable):
243+
"""Helper function to get sorted keys if possible."""
225244
try:
226-
if isinstance(variable, dict):
227-
return {"list": [(k, variable.get(k)) for k in sorted(variable)]}
228-
else:
229-
return {"list": [(k, variable.getlist(k)) for k in sorted(variable)]}
245+
return sorted(variable)
230246
except TypeError:
231-
return {"raw": variable}
247+
return list(variable)
248+
249+
250+
def _process_query_dict(query_dict, keys):
251+
"""Process a QueryDict into a list of (key, sanitized_value) tuples."""
252+
result = []
253+
for k in keys:
254+
values = query_dict.getlist(k)
255+
# Return single value if there's only one, otherwise keep as list
256+
value = values[0] if len(values) == 1 else values
257+
result.append((k, safe_filter.cleanse_setting(k, value)))
258+
return result
259+
260+
261+
def _process_dict(dictionary, keys):
262+
"""Process a dictionary into a list of (key, sanitized_value) tuples."""
263+
return [(k, safe_filter.cleanse_setting(k, dictionary.get(k))) for k in keys]
232264

233265

234266
def get_stack(context=1) -> list[stubs.InspectStack]:

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Pending
55
-------
66

77
* Added hook to RedirectsPanel for subclass customization.
8+
* Added feature to sanitize sensitive data in the Request Panel.
89

910
5.1.0 (2025-03-20)
1011
------------------

tests/panels/test_request.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,76 @@ def test_session_list_sorted_or_not(self):
136136
self.panel.generate_stats(self.request, response)
137137
panel_stats = self.panel.get_stats()
138138
self.assertEqual(panel_stats["session"], data)
139+
140+
def test_sensitive_post_data_sanitized(self):
141+
"""Test that sensitive POST data is redacted."""
142+
self.request.POST = {"username": "testuser", "password": "secret123"}
143+
response = self.panel.process_request(self.request)
144+
self.panel.generate_stats(self.request, response)
145+
146+
# Check that password is redacted in panel content
147+
content = self.panel.content
148+
self.assertIn("username", content)
149+
self.assertIn("testuser", content)
150+
self.assertIn("password", content)
151+
self.assertNotIn("secret123", content)
152+
self.assertIn("********************", content)
153+
154+
def test_sensitive_get_data_sanitized(self):
155+
"""Test that sensitive GET data is redacted."""
156+
self.request.GET = {"api_key": "abc123", "q": "search term"}
157+
response = self.panel.process_request(self.request)
158+
self.panel.generate_stats(self.request, response)
159+
160+
# Check that api_key is redacted in panel content
161+
content = self.panel.content
162+
self.assertIn("api_key", content)
163+
self.assertNotIn("abc123", content)
164+
self.assertIn("********************", content)
165+
self.assertIn("q", content)
166+
self.assertIn("search term", content)
167+
168+
def test_sensitive_cookie_data_sanitized(self):
169+
"""Test that sensitive cookie data is redacted."""
170+
self.request.COOKIES = {"session_id": "abc123", "auth_token": "xyz789"}
171+
response = self.panel.process_request(self.request)
172+
self.panel.generate_stats(self.request, response)
173+
174+
# Check that auth_token is redacted in panel content
175+
content = self.panel.content
176+
self.assertIn("session_id", content)
177+
self.assertIn("abc123", content)
178+
self.assertIn("auth_token", content)
179+
self.assertNotIn("xyz789", content)
180+
self.assertIn("********************", content)
181+
182+
def test_sensitive_session_data_sanitized(self):
183+
"""Test that sensitive session data is redacted."""
184+
self.request.session = {"user_id": 123, "auth_token": "xyz789"}
185+
response = self.panel.process_request(self.request)
186+
self.panel.generate_stats(self.request, response)
187+
188+
# Check that auth_token is redacted in panel content
189+
content = self.panel.content
190+
self.assertIn("user_id", content)
191+
self.assertIn("123", content)
192+
self.assertIn("auth_token", content)
193+
self.assertNotIn("xyz789", content)
194+
self.assertIn("********************", content)
195+
196+
def test_querydict_sanitized(self):
197+
"""Test that sensitive data in QueryDict objects is properly redacted."""
198+
query_dict = QueryDict("username=testuser&password=secret123&token=abc456")
199+
self.request.GET = query_dict
200+
response = self.panel.process_request(self.request)
201+
self.panel.generate_stats(self.request, response)
202+
203+
# Check that sensitive data is redacted in panel content
204+
content = self.panel.content
205+
self.assertIn("username", content)
206+
self.assertIn("testuser", content)
207+
self.assertIn("password", content)
208+
self.assertNotIn("secret123", content)
209+
self.assertIn("token", content)
210+
self.assertNotIn("abc456", content)
211+
self.assertIn("********************", content)

tests/test_utils.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import unittest
22

3+
from django.http import QueryDict
34
from django.test import override_settings
45

56
import debug_toolbar.utils
@@ -8,6 +9,7 @@
89
get_stack,
910
get_stack_trace,
1011
render_stacktrace,
12+
sanitize_and_sort_request_vars,
1113
tidy_stacktrace,
1214
)
1315

@@ -109,3 +111,63 @@ def __init__(self, value):
109111
rendered_stack_2 = render_stacktrace(stack_2_wrapper.value)
110112
self.assertNotIn("test_locals_value_1", rendered_stack_2)
111113
self.assertIn("test_locals_value_2", rendered_stack_2)
114+
115+
116+
class SanitizeAndSortRequestVarsTestCase(unittest.TestCase):
117+
"""Tests for the sanitize_and_sort_request_vars function."""
118+
119+
def test_dict_sanitization(self):
120+
"""Test sanitization of a regular dictionary."""
121+
test_dict = {
122+
"username": "testuser",
123+
"password": "secret123",
124+
"api_key": "abc123",
125+
}
126+
result = sanitize_and_sort_request_vars(test_dict)
127+
128+
# Convert to dict for easier testing
129+
result_dict = dict(result["list"])
130+
131+
self.assertEqual(result_dict["username"], "testuser")
132+
self.assertEqual(result_dict["password"], "********************")
133+
self.assertEqual(result_dict["api_key"], "********************")
134+
135+
def test_querydict_sanitization(self):
136+
"""Test sanitization of a QueryDict."""
137+
query_dict = QueryDict("username=testuser&password=secret123&api_key=abc123")
138+
result = sanitize_and_sort_request_vars(query_dict)
139+
140+
# Convert to dict for easier testing
141+
result_dict = dict(result["list"])
142+
143+
self.assertEqual(result_dict["username"], "testuser")
144+
self.assertEqual(result_dict["password"], "********************")
145+
self.assertEqual(result_dict["api_key"], "********************")
146+
147+
def test_non_sortable_dict_keys(self):
148+
"""Test dictionary with keys that can't be sorted."""
149+
test_dict = {
150+
1: "one",
151+
"2": "two",
152+
None: "none",
153+
}
154+
result = sanitize_and_sort_request_vars(test_dict)
155+
self.assertEqual(len(result["list"]), 3)
156+
result_dict = dict(result["list"])
157+
self.assertEqual(result_dict[1], "one")
158+
self.assertEqual(result_dict["2"], "two")
159+
self.assertEqual(result_dict[None], "none")
160+
161+
def test_querydict_multiple_values(self):
162+
"""Test QueryDict with multiple values for the same key."""
163+
query_dict = QueryDict("name=bar1&name=bar2&title=value")
164+
result = sanitize_and_sort_request_vars(query_dict)
165+
result_dict = dict(result["list"])
166+
self.assertEqual(result_dict["name"], ["bar1", "bar2"])
167+
self.assertEqual(result_dict["title"], "value")
168+
169+
def test_non_dict_input(self):
170+
"""Test handling of non-dict input."""
171+
test_input = ["not", "a", "dict"]
172+
result = sanitize_and_sort_request_vars(test_input)
173+
self.assertEqual(result["raw"], test_input)

0 commit comments

Comments
 (0)