diff --git a/django/views/debug.py b/django/views/debug.py index 3b37e8da1a..608282c232 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -25,10 +25,6 @@ DEBUG_ENGINE = Engine( libraries={'i18n': 'django.templatetags.i18n'}, ) -HIDDEN_SETTINGS = _lazy_re_compile('API|TOKEN|KEY|SECRET|PASS|SIGNATURE', flags=re.IGNORECASE) - -CLEANSED_SUBSTITUTE = '********************' - CURRENT_DIR = Path(__file__).parent @@ -46,42 +42,6 @@ class CallableSettingWrapper: return repr(self._wrapped) -def cleanse_setting(key, value): - """ - Cleanse an individual setting key/value of sensitive content. If the value - is a dictionary, recursively cleanse the keys in that dictionary. - """ - try: - if HIDDEN_SETTINGS.search(key): - cleansed = CLEANSED_SUBSTITUTE - else: - if isinstance(value, dict): - cleansed = {k: cleanse_setting(k, v) for k, v in value.items()} - else: - cleansed = value - except TypeError: - # If the key isn't regex-able, just return as-is. - cleansed = value - - if callable(cleansed): - # For fixing #21345 and #23070 - cleansed = CallableSettingWrapper(cleansed) - - return cleansed - - -def get_safe_settings(): - """ - Return a dictionary of the settings module with values of sensitive - settings replaced with stars (*********). - """ - settings_dict = {} - for k in dir(settings): - if k.isupper(): - settings_dict[k] = cleanse_setting(k, getattr(settings, k)) - return settings_dict - - def technical_500_response(request, exc_type, exc_value, tb, status_code=500): """ Create a technical server error response. The last three arguments are @@ -128,6 +88,40 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter): Use annotations made by the sensitive_post_parameters and sensitive_variables decorators to filter out sensitive information. """ + cleansed_substitute = '********************' + hidden_settings = _lazy_re_compile('API|TOKEN|KEY|SECRET|PASS|SIGNATURE', flags=re.I) + + def cleanse_setting(self, key, value): + """ + Cleanse an individual setting key/value of sensitive content. If the + value is a dictionary, recursively cleanse the keys in that dictionary. + """ + try: + if self.hidden_settings.search(key): + cleansed = self.cleansed_substitute + elif isinstance(value, dict): + cleansed = {k: self.cleanse_setting(k, v) for k, v in value.items()} + else: + cleansed = value + except TypeError: + # If the key isn't regex-able, just return as-is. + cleansed = value + + if callable(cleansed): + cleansed = CallableSettingWrapper(cleansed) + + return cleansed + + def get_safe_settings(self): + """ + Return a dictionary of the settings module with values of sensitive + settings replaced with stars (*********). + """ + settings_dict = {} + for k in dir(settings): + if k.isupper(): + settings_dict[k] = self.cleanse_setting(k, getattr(settings, k)) + return settings_dict def is_active(self, request): """ @@ -149,7 +143,7 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter): multivaluedict = multivaluedict.copy() for param in sensitive_post_parameters: if param in multivaluedict: - multivaluedict[param] = CLEANSED_SUBSTITUTE + multivaluedict[param] = self.cleansed_substitute return multivaluedict def get_post_parameters(self, request): @@ -166,13 +160,13 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter): if sensitive_post_parameters == '__ALL__': # Cleanse all parameters. for k in cleansed: - cleansed[k] = CLEANSED_SUBSTITUTE + cleansed[k] = self.cleansed_substitute return cleansed else: # Cleanse only the specified parameters. for param in sensitive_post_parameters: if param in cleansed: - cleansed[param] = CLEANSED_SUBSTITUTE + cleansed[param] = self.cleansed_substitute return cleansed else: return request.POST @@ -215,12 +209,12 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter): if sensitive_variables == '__ALL__': # Cleanse all variables for name in tb_frame.f_locals: - cleansed[name] = CLEANSED_SUBSTITUTE + cleansed[name] = self.cleansed_substitute else: # Cleanse specified variables for name, value in tb_frame.f_locals.items(): if name in sensitive_variables: - value = CLEANSED_SUBSTITUTE + value = self.cleansed_substitute else: value = self.cleanse_special_types(request, value) cleansed[name] = value @@ -236,8 +230,8 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter): # the sensitive_variables decorator's frame, in case the variables # associated with those arguments were meant to be obfuscated from # the decorated function's frame. - cleansed['func_args'] = CLEANSED_SUBSTITUTE - cleansed['func_kwargs'] = CLEANSED_SUBSTITUTE + cleansed['func_args'] = self.cleansed_substitute + cleansed['func_kwargs'] = self.cleansed_substitute return cleansed.items() @@ -304,7 +298,7 @@ class ExceptionReporter: 'request': self.request, 'user_str': user_str, 'filtered_POST_items': list(self.filter.get_post_parameters(self.request).items()), - 'settings': get_safe_settings(), + 'settings': self.filter.get_safe_settings(), 'sys_executable': sys.executable, 'sys_version_info': '%d.%d.%d' % sys.version_info[0:3], 'server_time': timezone.now(), @@ -506,6 +500,7 @@ def technical_404_response(request, exception): with Path(CURRENT_DIR, 'templates', 'technical_404.html').open(encoding='utf-8') as fh: t = DEBUG_ENGINE.from_string(fh.read()) + reporter_filter = get_default_exception_reporter_filter() c = Context({ 'urlconf': urlconf, 'root_urlconf': settings.ROOT_URLCONF, @@ -513,7 +508,7 @@ def technical_404_response(request, exception): 'urlpatterns': tried, 'reason': str(exception), 'request': request, - 'settings': get_safe_settings(), + 'settings': reporter_filter.get_safe_settings(), 'raising_view_name': caller, }) return HttpResponseNotFound(t.render(c), content_type='text/html') diff --git a/docs/howto/error-reporting.txt b/docs/howto/error-reporting.txt index 8521e01c61..a4cb5d2a1a 100644 --- a/docs/howto/error-reporting.txt +++ b/docs/howto/error-reporting.txt @@ -262,25 +262,46 @@ attribute:: Your custom filter class needs to inherit from :class:`django.views.debug.SafeExceptionReporterFilter` and may override the -following methods: +following attributes and methods: .. class:: SafeExceptionReporterFilter -.. method:: SafeExceptionReporterFilter.is_active(request) + .. attribute:: cleansed_substitute - Returns ``True`` to activate the filtering operated in the other methods. - By default the filter is active if :setting:`DEBUG` is ``False``. + .. versionadded:: 3.1 -.. method:: SafeExceptionReporterFilter.get_post_parameters(request) + The string value to replace sensitive value with. By default it + replaces the values of sensitive variables with stars (`**********`). - Returns the filtered dictionary of POST parameters. By default it replaces - the values of sensitive parameters with stars (`**********`). + .. attribute:: hidden_settings -.. method:: SafeExceptionReporterFilter.get_traceback_frame_variables(request, tb_frame) + .. versionadded:: 3.1 - Returns the filtered dictionary of local variables for the given traceback - frame. By default it replaces the values of sensitive variables with stars - (`**********`). + A compiled regular expression object used to match settings considered + as sensitive. By default equivalent to:: + + import re + + re.compile(r'API|TOKEN|KEY|SECRET|PASS|SIGNATURE', flags=re.IGNORECASE) + + .. method:: is_active(request) + + Returns ``True`` to activate the filtering in + :meth:`get_post_parameters` and :meth:`get_traceback_frame_variables`. + By default the filter is active if :setting:`DEBUG` is ``False``. Note + that sensitive settings are always filtered, as described in the + :setting:`DEBUG` documentation. + + .. method:: get_post_parameters(request) + + Returns the filtered dictionary of POST parameters. Sensitive values + are replaced with :attr:`cleansed_substitute`. + + .. method:: get_traceback_frame_variables(request, tb_frame) + + Returns the filtered dictionary of local variables for the given + traceback frame. Sensitive values are replaced with + :attr:`cleansed_substitute`. .. seealso:: diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 3def8920cc..48df0a0d66 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -158,6 +158,17 @@ Email * The :setting:`EMAIL_FILE_PATH` setting, used by the :ref:`file email backend `, now supports :class:`pathlib.Path`. +Error Reporting +~~~~~~~~~~~~~~~ + +* The new :attr:`.SafeExceptionReporterFilter.cleansed_substitute` and + :attr:`.SafeExceptionReporterFilter.hidden_settings` attributes allow + customization of sensitive settings filtering in exception reports. + +* The technical 404 debug view now respects + :setting:`DEFAULT_EXCEPTION_REPORTER_FILTER` when applying settings + filtering. + File Storage ~~~~~~~~~~~~ diff --git a/tests/view_tests/tests/test_debug.py b/tests/view_tests/tests/test_debug.py index 9b6f66e989..9becfc77d0 100644 --- a/tests/view_tests/tests/test_debug.py +++ b/tests/view_tests/tests/test_debug.py @@ -20,11 +20,13 @@ from django.test.utils import LoggingCaptureMixin from django.urls import path, reverse from django.urls.converters import IntConverter from django.utils.functional import SimpleLazyObject +from django.utils.regex_helper import _lazy_re_compile from django.utils.safestring import mark_safe from django.views.debug import ( - CLEANSED_SUBSTITUTE, CallableSettingWrapper, ExceptionReporter, - Path as DebugPath, cleanse_setting, default_urlconf, - technical_404_response, technical_500_response, + CallableSettingWrapper, ExceptionReporter, Path as DebugPath, + SafeExceptionReporterFilter, default_urlconf, + get_default_exception_reporter_filter, technical_404_response, + technical_500_response, ) from django.views.decorators.debug import ( sensitive_post_parameters, sensitive_variables, @@ -1199,6 +1201,66 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin response = self.client.get('/raises500/') self.assertNotContains(response, 'should not be displayed', status_code=500) + def test_cleanse_setting_basic(self): + reporter_filter = SafeExceptionReporterFilter() + self.assertEqual(reporter_filter.cleanse_setting('TEST', 'TEST'), 'TEST') + self.assertEqual( + reporter_filter.cleanse_setting('PASSWORD', 'super_secret'), + reporter_filter.cleansed_substitute, + ) + + def test_cleanse_setting_ignore_case(self): + reporter_filter = SafeExceptionReporterFilter() + self.assertEqual( + reporter_filter.cleanse_setting('password', 'super_secret'), + reporter_filter.cleansed_substitute, + ) + + def test_cleanse_setting_recurses_in_dictionary(self): + reporter_filter = SafeExceptionReporterFilter() + initial = {'login': 'cooper', 'password': 'secret'} + self.assertEqual( + reporter_filter.cleanse_setting('SETTING_NAME', initial), + {'login': 'cooper', 'password': reporter_filter.cleansed_substitute}, + ) + + +class CustomExceptionReporterFilter(SafeExceptionReporterFilter): + cleansed_substitute = 'XXXXXXXXXXXXXXXXXXXX' + hidden_settings = _lazy_re_compile('API|TOKEN|KEY|SECRET|PASS|SIGNATURE|DATABASE_URL', flags=re.I) + + +@override_settings( + ROOT_URLCONF='view_tests.urls', + DEFAULT_EXCEPTION_REPORTER_FILTER='%s.CustomExceptionReporterFilter' % __name__, +) +class CustomExceptionReporterFilterTests(SimpleTestCase): + def setUp(self): + get_default_exception_reporter_filter.cache_clear() + + def tearDown(self): + get_default_exception_reporter_filter.cache_clear() + + def test_setting_allows_custom_subclass(self): + self.assertIsInstance( + get_default_exception_reporter_filter(), + CustomExceptionReporterFilter, + ) + + def test_cleansed_substitute_override(self): + reporter_filter = get_default_exception_reporter_filter() + self.assertEqual( + reporter_filter.cleanse_setting('password', 'super_secret'), + reporter_filter.cleansed_substitute, + ) + + def test_hidden_settings_override(self): + reporter_filter = get_default_exception_reporter_filter() + self.assertEqual( + reporter_filter.cleanse_setting('database_url', 'super_secret'), + reporter_filter.cleansed_substitute, + ) + class AjaxResponseExceptionReporterFilter(ExceptionReportTestMixin, LoggingCaptureMixin, SimpleTestCase): """ @@ -1262,21 +1324,6 @@ class AjaxResponseExceptionReporterFilter(ExceptionReportTestMixin, LoggingCaptu self.assertEqual(response['Content-Type'], 'text/plain; charset=utf-8') -class HelperFunctionTests(SimpleTestCase): - - def test_cleanse_setting_basic(self): - self.assertEqual(cleanse_setting('TEST', 'TEST'), 'TEST') - self.assertEqual(cleanse_setting('PASSWORD', 'super_secret'), CLEANSED_SUBSTITUTE) - - def test_cleanse_setting_ignore_case(self): - self.assertEqual(cleanse_setting('password', 'super_secret'), CLEANSED_SUBSTITUTE) - - def test_cleanse_setting_recurses_in_dictionary(self): - initial = {'login': 'cooper', 'password': 'secret'} - expected = {'login': 'cooper', 'password': CLEANSED_SUBSTITUTE} - self.assertEqual(cleanse_setting('SETTING_NAME', initial), expected) - - class DecoratorsTests(SimpleTestCase): def test_sensitive_variables_not_called(self): msg = (