diff --git a/debug_toolbar/panels/request.py b/debug_toolbar/panels/request.py index b77788637..aa3d6bfaf 100644 --- a/debug_toolbar/panels/request.py +++ b/debug_toolbar/panels/request.py @@ -2,8 +2,13 @@ from django.urls import resolve from django.utils.translation import gettext_lazy as _ +from debug_toolbar import settings as dt_settings 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, + get_sorted_request_variable, + sanitize_value, +) class RequestPanel(Panel): @@ -58,14 +63,21 @@ def generate_stats(self, request, response): pass self.record_stats(view_info) + # Handle session data with sanitization if hasattr(request, "session"): + sanitize_request_data = dt_settings.get_config().get( + "SANITIZE_REQUEST_DATA", True + ) + try: - session_list = [ - (k, request.session.get(k)) for k in sorted(request.session.keys()) - ] + session_keys = sorted(request.session.keys()) except TypeError: - session_list = [ - (k, request.session.get(k)) - for k in request.session.keys() # (it's not a dict) - ] + # Handle non-dict session objects + session_keys = request.session.keys() + + session_list = [(k, request.session.get(k)) for k in session_keys] + + if sanitize_request_data: + session_list = [(k, sanitize_value(k, v)) for k, v in session_list] + self.record_stats({"session": {"list": session_list}}) diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index e0be35ea8..a0ef9682b 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -45,6 +45,17 @@ "TOOLBAR_LANGUAGE": None, "IS_RUNNING_TESTS": "test" in sys.argv, "UPDATE_ON_FETCH": False, + "SANITIZE_REQUEST_DATA": True, + "REQUEST_SANITIZATION_PATTERNS": ( + "API", + "AUTH", + "TOKEN", + "KEY", + "SECRET", + "PASS", + "SIGNATURE", + "HTTP_COOKIE", + ), "DEFAULT_THEME": "auto", } diff --git a/debug_toolbar/utils.py b/debug_toolbar/utils.py index dc3cc1adc..df7c0afd5 100644 --- a/debug_toolbar/utils.py +++ b/debug_toolbar/utils.py @@ -3,6 +3,7 @@ import inspect import linecache import os.path +import re import sys import warnings from collections.abc import Sequence @@ -220,17 +221,75 @@ def get_sorted_request_variable( ) -> dict[str, list[tuple[str, Any]] | Any]: """ Get a data structure for showing a sorted list of variables from the - request data. + request data. If enabled, sanitizes sensitive information. """ + config = dt_settings.get_config() + sanitize_request_data = config.get("SANITIZE_REQUEST_DATA", True) + try: if isinstance(variable, dict): - return {"list": [(k, variable.get(k)) for k in sorted(variable)]} + if sanitize_request_data: + return { + "list": [ + (k, sanitize_value(k, variable.get(k))) + for k in sorted(variable) + ] + } + else: + return {"list": [(k, variable.get(k)) for k in sorted(variable)]} else: - return {"list": [(k, variable.getlist(k)) for k in sorted(variable)]} + # Handle QueryDict which can have multiple values per key + if sanitize_request_data: + sanitized_list = [] + for k in sorted(variable): + values = variable.getlist(k) + sanitized_values = sanitize_value(k, values) + sanitized_list.append((k, sanitized_values)) + return {"list": sanitized_list} + else: + return {"list": [(k, variable.getlist(k)) for k in sorted(variable)]} except TypeError: return {"raw": variable} +def sanitize_value(key: str, value: Any) -> Any: + """ + Sanitize a potentially sensitive value based on its key. + """ + config = dt_settings.get_config() + + if not config.get("SANITIZE_REQUEST_DATA", True): + return value + + cleansed_substitute = "********************" + + # If key is not a string, we can't match it against our patterns + # so we just return the value unchanged + if not isinstance(key, str): + return value + + patterns = config.get( + "REQUEST_SANITIZATION_PATTERNS", + ( + "API", + "AUTH", + "TOKEN", + "KEY", + "SECRET", + "PASS", + "SIGNATURE", + "HTTP_COOKIE", + ), + ) + + if patterns: + pattern = re.compile("|".join(patterns), flags=re.I) + if pattern.search(key): + return cleansed_substitute + + return value + + def get_stack(context=1) -> list[stubs.InspectStack]: """ Get a list of records for a frame and all higher (calling) frames. diff --git a/docs/changes.rst b/docs/changes.rst index f11d4889e..47546f2bc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -16,6 +16,7 @@ Pending of each target. * Avoided reinitializing the staticfiles storage during instrumentation. * Fix for exception-unhandled "forked" Promise chain in rebound window.fetch +* Added optional settings to sanitize sensitive data in the Request panel. 5.0.1 (2025-01-13) ------------------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 7cd6bc11b..3463ecf9b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -385,6 +385,50 @@ Here's what a slightly customized toolbar configuration might look like:: 'SQL_WARNING_THRESHOLD': 100, # milliseconds } +.. _SANITIZE_REQUEST_DATA: + +* ``SANITIZE_REQUEST_DATA`` + + Default: ``True`` + + Panel: request + + This controls whether the toolbar should sanitize the request data + before rendering it. This helps prevent sensitive information from + being displayed in the Debug Toolbar panels or stored in any + persistent storage that might be used. + + This setting sanitizes data in: + + * GET parameters + * POST parameters + * Cookies + * Session data + + Sanitized data is substituted with the string ``"********************"``. + +.. _REQUEST_SANITIZATION_PATTERNS: + +* ``REQUEST_SANITIZATION_PATTERNS`` + + Default:: + + ( + "API", + "AUTH", + "TOKEN", + "KEY", + "SECRET", + "PASS", + "SIGNATURE", + "HTTP_COOKIE", + ) + + Panel: request + + This controls the patterns that the toolbar should use to sanitize + the request data. + Theming support --------------- The debug toolbar uses CSS variables to define fonts and colors. This allows diff --git a/tests/panels/test_request.py b/tests/panels/test_request.py index 707b50bb4..556d3729c 100644 --- a/tests/panels/test_request.py +++ b/tests/panels/test_request.py @@ -1,5 +1,5 @@ from django.http import QueryDict -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from ..base import BaseTestCase @@ -136,3 +136,73 @@ 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_sanitization(self): + """Test that sensitive POST data is sanitized.""" + 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 sanitized 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_sanitization(self): + """Test that sensitive GET data is sanitized.""" + 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 sanitized 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_sanitization(self): + """Test that sensitive cookie data is sanitized.""" + 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 sanitized 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_sanitization(self): + """Test that sensitive session data is sanitized.""" + 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 sanitized 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) + + @override_settings(DEBUG_TOOLBAR_CONFIG={"SANITIZE_REQUEST_DATA": False}) + def test_sanitization_disabled_in_panel(self): + """Test that sanitization can be disabled for the panel.""" + self.request.POST = {"username": "testuser", "password": "secret123"} + response = self.panel.process_request(self.request) + self.panel.generate_stats(self.request, response) + + # With sanitization disabled, password should appear in content + content = self.panel.content + self.assertIn("username", content) + self.assertIn("testuser", content) + self.assertIn("password", content) + self.assertIn("secret123", content) diff --git a/tests/test_utils.py b/tests/test_utils.py index 26bfce005..f1b9c9bf4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,13 +1,16 @@ import unittest +from django.http import QueryDict from django.test import override_settings import debug_toolbar.utils from debug_toolbar.utils import ( get_name_from_obj, + get_sorted_request_variable, get_stack, get_stack_trace, render_stacktrace, + sanitize_value, tidy_stacktrace, ) @@ -109,3 +112,84 @@ 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 SanitizeValueTestCase(unittest.TestCase): + """Tests for the sanitize_value function.""" + + def test_sanitize_sensitive_key(self): + """Test that sensitive keys are sanitized.""" + self.assertEqual( + sanitize_value("password", "secret123"), "********************" + ) + self.assertEqual(sanitize_value("api_key", "abc123"), "********************") + self.assertEqual(sanitize_value("auth_token", "xyz789"), "********************") + + def test_sanitize_non_sensitive_key(self): + """Test that non-sensitive keys are not sanitized.""" + self.assertEqual(sanitize_value("username", "testuser"), "testuser") + self.assertEqual( + sanitize_value("email", "user@example.com"), "user@example.com" + ) + + @override_settings(DEBUG_TOOLBAR_CONFIG={"SANITIZE_REQUEST_DATA": False}) + def test_sanitize_disabled(self): + """Test that sanitization can be disabled.""" + self.assertEqual(sanitize_value("password", "secret123"), "secret123") + + @override_settings( + DEBUG_TOOLBAR_CONFIG={"REQUEST_SANITIZATION_PATTERNS": ("CUSTOM",)} + ) + def test_custom_sanitization_patterns(self): + """Test that custom sanitization patterns can be used.""" + self.assertEqual( + sanitize_value("custom_field", "sensitive"), "********************" + ) + self.assertEqual( + sanitize_value("password", "secret123"), + "secret123", # Not sanitized with custom pattern + ) + + +class GetSortedRequestVariableTestCase(unittest.TestCase): + """Tests for the get_sorted_request_variable function.""" + + def test_dict_sanitization(self): + """Test sanitization of a regular dictionary.""" + test_dict = { + "username": "testuser", + "password": "secret123", + "api_key": "abc123", + } + result = get_sorted_request_variable(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 = get_sorted_request_variable(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"], "********************") + + @override_settings(DEBUG_TOOLBAR_CONFIG={"SANITIZE_REQUEST_DATA": False}) + def test_sanitization_disabled(self): + """Test that sanitization can be disabled.""" + test_dict = {"username": "testuser", "password": "secret123"} + result = get_sorted_request_variable(test_dict) + + # Convert to dict for easier testing + result_dict = dict(result["list"]) + + self.assertEqual(result_dict["username"], "testuser") + self.assertEqual(result_dict["password"], "secret123") # Not sanitized