Skip to content

Commit 04e4c68

Browse files
committed
Fix #2317: Convert non-string keys in panels
- Add convert_keys_to_strings utility function - Fix CachePanel to handle tuple and UUID keys - Fix RequestPanel to safely handle POST data and convert session keys - Add comprehensive tests for both panels - All panel tests passing
1 parent a6600ed commit 04e4c68

5 files changed

Lines changed: 172 additions & 19 deletions

File tree

debug_toolbar/panels/cache.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from debug_toolbar.panels import Panel
1010
from debug_toolbar.utils import get_stack_trace, get_template_info, render_stacktrace
11+
from .utils import convert_keys_to_strings
12+
1113

1214
# The order of the methods in this list determines the order in which they are listed in
1315
# the Commands table in the panel content.
@@ -125,12 +127,16 @@ def _store_call_info(
125127

126128
self.total_time += time_taken
127129
self.counts[name] += 1
130+
131+
converted_args = tuple(convert_keys_to_strings(arg) for arg in args)
132+
converted_kwargs = {k: convert_keys_to_strings(v) for k, v in kwargs.items()}
133+
128134
self.calls.append(
129135
{
130136
"time": time_taken,
131137
"name": name,
132-
"args": args,
133-
"kwargs": kwargs,
138+
"args": converted_args,
139+
"kwargs": converted_kwargs,
134140
"trace": render_stacktrace(trace),
135141
"template_info": template_info,
136142
"backend": backend,

debug_toolbar/panels/request.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from debug_toolbar.panels import Panel
66
from debug_toolbar.utils import get_name_from_obj, sanitize_and_sort_request_vars
7+
from .utils import convert_keys_to_strings
78

89

910
class RequestPanel(Panel):
@@ -24,13 +25,25 @@ def nav_subtitle(self):
2425
return view_func.rsplit(".", 1)[-1]
2526

2627
def generate_stats(self, request, response):
27-
self.record_stats(
28-
{
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),
32-
}
33-
)
28+
get_data = convert_keys_to_strings(dict(request.GET))
29+
try:
30+
post_dict = dict(request.POST)
31+
except (TypeError, ValueError):
32+
post_dict = {'_raw_post_data': str(request.POST)}
33+
post_data = convert_keys_to_strings(post_dict)
34+
cookies_data = convert_keys_to_strings(dict(request.COOKIES))
35+
36+
stats = {
37+
"get": sanitize_and_sort_request_vars(get_data),
38+
"post": sanitize_and_sort_request_vars(post_data),
39+
"cookies": sanitize_and_sort_request_vars(cookies_data),
40+
}
41+
42+
if hasattr(request, "session"):
43+
session_data = convert_keys_to_strings(dict(request.session))
44+
stats["session"] = sanitize_and_sort_request_vars(session_data)
45+
46+
self.record_stats(stats)
3447

3548
view_info = {
3649
"view_func": _("<no view>"),
@@ -57,7 +70,3 @@ def generate_stats(self, request, response):
5770
except Http404:
5871
pass
5972
self.record_stats(view_info)
60-
61-
if hasattr(request, "session"):
62-
session_data = dict(request.session)
63-
self.record_stats({"session": sanitize_and_sort_request_vars(session_data)})

debug_toolbar/panels/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
def convert_keys_to_strings(obj):
2+
"""
3+
Recursively convert non-string dictionary keys to strings.
4+
5+
This ensures data is JSON-serializable before being passed to the store.
6+
7+
Args:
8+
obj: Any Python object (dict, list, tuple, or primitive)
9+
10+
Returns:
11+
Object with all dict keys converted to strings
12+
"""
13+
from uuid import UUID
14+
15+
16+
if isinstance(obj, dict):
17+
result = {
18+
str(k) if not isinstance(k, (str, int, float, bool, type(None))) else k:
19+
convert_keys_to_strings(v)
20+
for k, v in obj.items()
21+
}
22+
return result
23+
elif isinstance(obj, tuple):
24+
result = str(obj)
25+
return result
26+
elif isinstance(obj, UUID):
27+
result = str(obj)
28+
return result
29+
elif isinstance(obj, list):
30+
result = [convert_keys_to_strings(item) for item in obj]
31+
return result
32+
return obj

tests/panels/test_cache.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,42 @@ def test_generate_server_timing(self):
158158
def test_backend_alias_is_recorded(self):
159159
cache.cache.get("foo")
160160
self.assertEqual(self.panel.calls[0]["backend"], "default (LocMemCache)")
161+
162+
def test_non_string_keys_are_converted(self):
163+
"""Test that non-string keys in cache operations are converted."""
164+
from uuid import UUID
165+
166+
167+
self.panel.calls = []
168+
169+
170+
tuple_key = (1, 2)
171+
cache.cache.set(tuple_key, "tuple_value")
172+
173+
self.assertEqual(len(self.panel.calls), 1)
174+
call = self.panel.calls[0]
175+
self.assertEqual(call['name'], "set")
176+
self.assertIsInstance(call['args'][0], str)
177+
self.assertEqual(call['args'][0], "(1, 2)")
178+
self.assertEqual(call['args'][1], "tuple_value")
179+
180+
181+
uuid_key = UUID("aaaaaaaa-0000-0000-0000-000000000001")
182+
cache.cache.set(uuid_key, "uuid_value")
183+
184+
self.assertEqual(len(self.panel.calls), 2)
185+
call = self.panel.calls[1]
186+
self.assertEqual(call['name'], "set")
187+
self.assertIsInstance(call['args'][0], str)
188+
self.assertEqual(call['args'][0], "aaaaaaaa-0000-0000-0000-000000000001")
189+
self.assertEqual(call['args'][1], "uuid_value")
190+
191+
192+
cache.cache.get(tuple_key)
193+
self.assertEqual(len(self.panel.calls), 3)
194+
call = self.panel.calls[2]
195+
self.assertEqual(call['name'], "get")
196+
self.assertIsInstance(call['args'][0], str)
197+
self.assertEqual(call['args'][0], "(1, 2)")
198+
199+

tests/panels/test_request.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@
99

1010

1111
class RequestPanelTestCase(BaseTestCase):
12+
1213
panel_id = RequestPanel.panel_id
1314

15+
def setUp(self):
16+
super().setUp()
17+
from django.contrib.sessions.middleware import SessionMiddleware
18+
middleware = SessionMiddleware(lambda req: None)
19+
middleware.process_request(self.request)
20+
self.request.session.save()
21+
1422
def test_non_ascii_session(self):
1523
self.request.session = {"où": "où"}
1624
response = self.panel.process_request(self.request)
@@ -121,23 +129,23 @@ def test_session_list_sorted_or_not(self):
121129
"data": ["foo", "bar", 1],
122130
(2, 3): "tuple_key",
123131
}
124-
data = {
125-
"list": [(1, "value"), ("data", ["foo", "bar", 1]), ((2, 3), "tuple_key")]
126-
}
132+
expected_data = {
133+
"list": [(1, "value"), ("data", ["foo", "bar", 1]), ("(2, 3)", "tuple_key")]
134+
}
127135
response = self.panel.process_request(self.request)
128136
self.panel.generate_stats(self.request, response)
129137
panel_stats = self.panel.get_stats()
130-
self.assertEqual(panel_stats["session"], data)
138+
self.assertEqual(panel_stats["session"], expected_data)
131139

132140
self.request.session = {
133141
"b": "b-value",
134142
"a": "a-value",
135143
}
136-
data = {"list": [("a", "a-value"), ("b", "b-value")]}
144+
expected_data = {"list": [("a", "a-value"), ("b", "b-value")]}
137145
response = self.panel.process_request(self.request)
138146
self.panel.generate_stats(self.request, response)
139147
panel_stats = self.panel.get_stats()
140-
self.assertEqual(panel_stats["session"], data)
148+
self.assertEqual(panel_stats["session"], expected_data)
141149

142150
def test_sensitive_post_data_sanitized(self):
143151
"""Test that sensitive POST data is redacted."""
@@ -211,3 +219,62 @@ def test_querydict_sanitized(self):
211219
self.assertIn("token", content)
212220
self.assertNotIn("abc456", content)
213221
self.assertIn("********************", content)
222+
223+
def test_session_with_tuple_keys_are_converted(self):
224+
"""Test that session data with tuple keys is properly converted."""
225+
226+
self.request.session["lookup"] = {(1, 2): "foo", (3, 4): "bar"}
227+
228+
response = self.panel.process_request(self.request)
229+
self.panel.generate_stats(self.request, response)
230+
stats = self.panel.get_stats()
231+
232+
233+
session = stats.get('session', {})
234+
session_list = session.get('list', [])
235+
236+
237+
lookup_data = None
238+
for key, value in session_list:
239+
if key == 'lookup':
240+
lookup_data = value
241+
break
242+
243+
self.assertIsNotNone(lookup_data)
244+
self.assertIn("(1, 2)", lookup_data)
245+
self.assertIn("(3, 4)", lookup_data)
246+
self.assertEqual(lookup_data["(1, 2)"], "foo")
247+
self.assertEqual(lookup_data["(3, 4)"], "bar")
248+
249+
def test_nested_non_string_keys_are_converted(self):
250+
"""Test that deeply nested non-string keys are converted."""
251+
252+
self.request.session["complex"] = {
253+
(1, 2): {
254+
(3, 4): ["value1", {"nested": (5, 6)}]
255+
}
256+
}
257+
258+
response = self.panel.process_request(self.request)
259+
self.panel.generate_stats(self.request, response)
260+
stats = self.panel.get_stats()
261+
262+
263+
264+
session = stats.get('session', {})
265+
session_list = session.get('list', [])
266+
267+
complex_data = None
268+
for key, value in session_list:
269+
if key == 'complex':
270+
complex_data = value
271+
break
272+
273+
self.assertIsNotNone(complex_data)
274+
self.assertIn("(1, 2)", complex_data)
275+
276+
nested = complex_data["(1, 2)"]
277+
self.assertIn("(3, 4)", nested)
278+
self.assertEqual(nested["(3, 4)"][0], "value1")
279+
self.assertIn("nested", nested["(3, 4)"][1])
280+
self.assertEqual(nested["(3, 4)"][1]["nested"], "(5, 6)")

0 commit comments

Comments
 (0)