Skip to content

Sanitize sensitive variables in RequestPanel #2105

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
20 changes: 6 additions & 14 deletions debug_toolbar/panels/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.utils.translation import gettext_lazy as _

from debug_toolbar.panels import Panel
from debug_toolbar.utils import get_name_from_obj, get_sorted_request_variable
from debug_toolbar.utils import get_name_from_obj, sanitize_and_sort_request_vars


class RequestPanel(Panel):
Expand All @@ -26,9 +26,9 @@ def nav_subtitle(self):
def generate_stats(self, request, response):
self.record_stats(
{
"get": get_sorted_request_variable(request.GET),
"post": get_sorted_request_variable(request.POST),
"cookies": get_sorted_request_variable(request.COOKIES),
"get": sanitize_and_sort_request_vars(request.GET),
"post": sanitize_and_sort_request_vars(request.POST),
"cookies": sanitize_and_sort_request_vars(request.COOKIES),
}
)

Expand Down Expand Up @@ -59,13 +59,5 @@ def generate_stats(self, request, response):
self.record_stats(view_info)

if hasattr(request, "session"):
try:
session_list = [
(k, request.session.get(k)) for k in sorted(request.session.keys())
]
except TypeError:
session_list = [
(k, request.session.get(k))
for k in request.session.keys() # (it's not a dict)
]
self.record_stats({"session": {"list": session_list}})
session_data = dict(request.session)
self.record_stats({"session": sanitize_and_sort_request_vars(session_data)})
46 changes: 39 additions & 7 deletions debug_toolbar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
from django.template import Node
from django.utils.html import format_html
from django.utils.safestring import SafeString, mark_safe
from django.views.debug import get_default_exception_reporter_filter

from debug_toolbar import _stubs as stubs, settings as dt_settings

_local_data = Local()
safe_filter = get_default_exception_reporter_filter()


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


def get_sorted_request_variable(
def sanitize_and_sort_request_vars(
variable: dict[str, Any] | QueryDict,
) -> dict[str, list[tuple[str, Any]] | Any]:
"""
Get a data structure for showing a sorted list of variables from the
request data.
request data with sensitive values redacted.
"""
if not isinstance(variable, (dict, QueryDict)):
return {"raw": variable}

# Get sorted keys if possible, otherwise just list them
keys = _get_sorted_keys(variable)

# Process the variable based on its type
if isinstance(variable, QueryDict):
result = _process_query_dict(variable, keys)
else:
result = _process_dict(variable, keys)

return {"list": result}


def _get_sorted_keys(variable):
"""Helper function to get sorted keys if possible."""
try:
if isinstance(variable, dict):
return {"list": [(k, variable.get(k)) for k in sorted(variable)]}
else:
return {"list": [(k, variable.getlist(k)) for k in sorted(variable)]}
return sorted(variable)
except TypeError:
return {"raw": variable}
return list(variable)


def _process_query_dict(query_dict, keys):
"""Process a QueryDict into a list of (key, sanitized_value) tuples."""
result = []
for k in keys:
values = query_dict.getlist(k)
# Return single value if there's only one, otherwise keep as list
value = values[0] if len(values) == 1 else values
result.append((k, safe_filter.cleanse_setting(k, value)))
return result


def _process_dict(dictionary, keys):
"""Process a dictionary into a list of (key, sanitized_value) tuples."""
return [(k, safe_filter.cleanse_setting(k, dictionary.get(k))) for k in keys]


def get_stack(context=1) -> list[stubs.InspectStack]:
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Pending
-------

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

5.1.0 (2025-03-20)
------------------
Expand Down
73 changes: 73 additions & 0 deletions tests/panels/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,76 @@ def test_session_list_sorted_or_not(self):
self.panel.generate_stats(self.request, response)
panel_stats = self.panel.get_stats()
self.assertEqual(panel_stats["session"], data)

def test_sensitive_post_data_sanitized(self):
"""Test that sensitive POST data is redacted."""
self.request.POST = {"username": "testuser", "password": "secret123"}
response = self.panel.process_request(self.request)
self.panel.generate_stats(self.request, response)

# Check that password is redacted in panel content
content = self.panel.content
self.assertIn("username", content)
self.assertIn("testuser", content)
self.assertIn("password", content)
self.assertNotIn("secret123", content)
self.assertIn("********************", content)

def test_sensitive_get_data_sanitized(self):
"""Test that sensitive GET data is redacted."""
self.request.GET = {"api_key": "abc123", "q": "search term"}
response = self.panel.process_request(self.request)
self.panel.generate_stats(self.request, response)

# Check that api_key is redacted in panel content
content = self.panel.content
self.assertIn("api_key", content)
self.assertNotIn("abc123", content)
self.assertIn("********************", content)
self.assertIn("q", content)
self.assertIn("search term", content)

def test_sensitive_cookie_data_sanitized(self):
"""Test that sensitive cookie data is redacted."""
self.request.COOKIES = {"session_id": "abc123", "auth_token": "xyz789"}
response = self.panel.process_request(self.request)
self.panel.generate_stats(self.request, response)

# Check that auth_token is redacted in panel content
content = self.panel.content
self.assertIn("session_id", content)
self.assertIn("abc123", content)
self.assertIn("auth_token", content)
self.assertNotIn("xyz789", content)
self.assertIn("********************", content)

def test_sensitive_session_data_sanitized(self):
"""Test that sensitive session data is redacted."""
self.request.session = {"user_id": 123, "auth_token": "xyz789"}
response = self.panel.process_request(self.request)
self.panel.generate_stats(self.request, response)

# Check that auth_token is redacted in panel content
content = self.panel.content
self.assertIn("user_id", content)
self.assertIn("123", content)
self.assertIn("auth_token", content)
self.assertNotIn("xyz789", content)
self.assertIn("********************", content)

def test_querydict_sanitized(self):
"""Test that sensitive data in QueryDict objects is properly redacted."""
query_dict = QueryDict("username=testuser&password=secret123&token=abc456")
self.request.GET = query_dict
response = self.panel.process_request(self.request)
self.panel.generate_stats(self.request, response)

# Check that sensitive data is redacted in panel content
content = self.panel.content
self.assertIn("username", content)
self.assertIn("testuser", content)
self.assertIn("password", content)
self.assertNotIn("secret123", content)
self.assertIn("token", content)
self.assertNotIn("abc456", content)
self.assertIn("********************", content)
62 changes: 62 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import unittest

from django.http import QueryDict
from django.test import override_settings

import debug_toolbar.utils
Expand All @@ -8,6 +9,7 @@
get_stack,
get_stack_trace,
render_stacktrace,
sanitize_and_sort_request_vars,
tidy_stacktrace,
)

Expand Down Expand Up @@ -109,3 +111,63 @@ def __init__(self, value):
rendered_stack_2 = render_stacktrace(stack_2_wrapper.value)
self.assertNotIn("test_locals_value_1", rendered_stack_2)
self.assertIn("test_locals_value_2", rendered_stack_2)


class SanitizeAndSortRequestVarsTestCase(unittest.TestCase):
"""Tests for the sanitize_and_sort_request_vars function."""

def test_dict_sanitization(self):
"""Test sanitization of a regular dictionary."""
test_dict = {
"username": "testuser",
"password": "secret123",
"api_key": "abc123",
}
result = sanitize_and_sort_request_vars(test_dict)

# Convert to dict for easier testing
result_dict = dict(result["list"])

self.assertEqual(result_dict["username"], "testuser")
self.assertEqual(result_dict["password"], "********************")
self.assertEqual(result_dict["api_key"], "********************")

def test_querydict_sanitization(self):
"""Test sanitization of a QueryDict."""
query_dict = QueryDict("username=testuser&password=secret123&api_key=abc123")
result = sanitize_and_sort_request_vars(query_dict)

# Convert to dict for easier testing
result_dict = dict(result["list"])

self.assertEqual(result_dict["username"], "testuser")
self.assertEqual(result_dict["password"], "********************")
self.assertEqual(result_dict["api_key"], "********************")

def test_non_sortable_dict_keys(self):
"""Test dictionary with keys that can't be sorted."""
test_dict = {
1: "one",
"2": "two",
None: "none",
}
result = sanitize_and_sort_request_vars(test_dict)
self.assertEqual(len(result["list"]), 3)
result_dict = dict(result["list"])
self.assertEqual(result_dict[1], "one")
self.assertEqual(result_dict["2"], "two")
self.assertEqual(result_dict[None], "none")

def test_querydict_multiple_values(self):
"""Test QueryDict with multiple values for the same key."""
query_dict = QueryDict("name=bar1&name=bar2&title=value")
result = sanitize_and_sort_request_vars(query_dict)
result_dict = dict(result["list"])
self.assertEqual(result_dict["name"], ["bar1", "bar2"])
self.assertEqual(result_dict["title"], "value")

def test_non_dict_input(self):
"""Test handling of non-dict input."""
test_input = ["not", "a", "dict"]
result = sanitize_and_sort_request_vars(test_input)
self.assertEqual(result["raw"], test_input)