Skip to content

Add data sanitization to RequestPanel #2103

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

Closed
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
28 changes: 20 additions & 8 deletions debug_toolbar/panels/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}})
11 changes: 11 additions & 0 deletions debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
65 changes: 62 additions & 3 deletions debug_toolbar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import inspect
import linecache
import os.path
import re
import sys
import warnings
from collections.abc import Sequence
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------
Expand Down
44 changes: 44 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 71 additions & 1 deletion tests/panels/test_request.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
84 changes: 84 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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,
)

Expand Down Expand Up @@ -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", "[email protected]"), "[email protected]"
)

@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