Refs #23004 -- Allowed exception reporter filters to customize settings filtering.

Thanks to Tim Graham for the original implementation idea.

Co-authored-by: Daniel Maxson <dmaxson@ccpgames.com>
This commit is contained in:
Carlton Gibson 2020-01-09 10:00:07 +01:00 committed by Mariusz Felisiak
parent 5166097d7c
commit 581ba5a948
4 changed files with 152 additions and 78 deletions

View File

@ -25,10 +25,6 @@ DEBUG_ENGINE = Engine(
libraries={'i18n': 'django.templatetags.i18n'}, 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 CURRENT_DIR = Path(__file__).parent
@ -46,42 +42,6 @@ class CallableSettingWrapper:
return repr(self._wrapped) 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): def technical_500_response(request, exc_type, exc_value, tb, status_code=500):
""" """
Create a technical server error response. The last three arguments are 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 Use annotations made by the sensitive_post_parameters and
sensitive_variables decorators to filter out sensitive information. 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): def is_active(self, request):
""" """
@ -149,7 +143,7 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter):
multivaluedict = multivaluedict.copy() multivaluedict = multivaluedict.copy()
for param in sensitive_post_parameters: for param in sensitive_post_parameters:
if param in multivaluedict: if param in multivaluedict:
multivaluedict[param] = CLEANSED_SUBSTITUTE multivaluedict[param] = self.cleansed_substitute
return multivaluedict return multivaluedict
def get_post_parameters(self, request): def get_post_parameters(self, request):
@ -166,13 +160,13 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter):
if sensitive_post_parameters == '__ALL__': if sensitive_post_parameters == '__ALL__':
# Cleanse all parameters. # Cleanse all parameters.
for k in cleansed: for k in cleansed:
cleansed[k] = CLEANSED_SUBSTITUTE cleansed[k] = self.cleansed_substitute
return cleansed return cleansed
else: else:
# Cleanse only the specified parameters. # Cleanse only the specified parameters.
for param in sensitive_post_parameters: for param in sensitive_post_parameters:
if param in cleansed: if param in cleansed:
cleansed[param] = CLEANSED_SUBSTITUTE cleansed[param] = self.cleansed_substitute
return cleansed return cleansed
else: else:
return request.POST return request.POST
@ -215,12 +209,12 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter):
if sensitive_variables == '__ALL__': if sensitive_variables == '__ALL__':
# Cleanse all variables # Cleanse all variables
for name in tb_frame.f_locals: for name in tb_frame.f_locals:
cleansed[name] = CLEANSED_SUBSTITUTE cleansed[name] = self.cleansed_substitute
else: else:
# Cleanse specified variables # Cleanse specified variables
for name, value in tb_frame.f_locals.items(): for name, value in tb_frame.f_locals.items():
if name in sensitive_variables: if name in sensitive_variables:
value = CLEANSED_SUBSTITUTE value = self.cleansed_substitute
else: else:
value = self.cleanse_special_types(request, value) value = self.cleanse_special_types(request, value)
cleansed[name] = value cleansed[name] = value
@ -236,8 +230,8 @@ class SafeExceptionReporterFilter(ExceptionReporterFilter):
# the sensitive_variables decorator's frame, in case the variables # the sensitive_variables decorator's frame, in case the variables
# associated with those arguments were meant to be obfuscated from # associated with those arguments were meant to be obfuscated from
# the decorated function's frame. # the decorated function's frame.
cleansed['func_args'] = CLEANSED_SUBSTITUTE cleansed['func_args'] = self.cleansed_substitute
cleansed['func_kwargs'] = CLEANSED_SUBSTITUTE cleansed['func_kwargs'] = self.cleansed_substitute
return cleansed.items() return cleansed.items()
@ -304,7 +298,7 @@ class ExceptionReporter:
'request': self.request, 'request': self.request,
'user_str': user_str, 'user_str': user_str,
'filtered_POST_items': list(self.filter.get_post_parameters(self.request).items()), '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_executable': sys.executable,
'sys_version_info': '%d.%d.%d' % sys.version_info[0:3], 'sys_version_info': '%d.%d.%d' % sys.version_info[0:3],
'server_time': timezone.now(), '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: with Path(CURRENT_DIR, 'templates', 'technical_404.html').open(encoding='utf-8') as fh:
t = DEBUG_ENGINE.from_string(fh.read()) t = DEBUG_ENGINE.from_string(fh.read())
reporter_filter = get_default_exception_reporter_filter()
c = Context({ c = Context({
'urlconf': urlconf, 'urlconf': urlconf,
'root_urlconf': settings.ROOT_URLCONF, 'root_urlconf': settings.ROOT_URLCONF,
@ -513,7 +508,7 @@ def technical_404_response(request, exception):
'urlpatterns': tried, 'urlpatterns': tried,
'reason': str(exception), 'reason': str(exception),
'request': request, 'request': request,
'settings': get_safe_settings(), 'settings': reporter_filter.get_safe_settings(),
'raising_view_name': caller, 'raising_view_name': caller,
}) })
return HttpResponseNotFound(t.render(c), content_type='text/html') return HttpResponseNotFound(t.render(c), content_type='text/html')

View File

@ -262,25 +262,46 @@ attribute::
Your custom filter class needs to inherit from Your custom filter class needs to inherit from
:class:`django.views.debug.SafeExceptionReporterFilter` and may override the :class:`django.views.debug.SafeExceptionReporterFilter` and may override the
following methods: following attributes and methods:
.. class:: SafeExceptionReporterFilter .. class:: SafeExceptionReporterFilter
.. method:: SafeExceptionReporterFilter.is_active(request) .. attribute:: cleansed_substitute
Returns ``True`` to activate the filtering operated in the other methods. .. versionadded:: 3.1
By default the filter is active if :setting:`DEBUG` is ``False``.
.. 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 .. attribute:: hidden_settings
the values of sensitive parameters with stars (`**********`).
.. method:: SafeExceptionReporterFilter.get_traceback_frame_variables(request, tb_frame) .. versionadded:: 3.1
Returns the filtered dictionary of local variables for the given traceback A compiled regular expression object used to match settings considered
frame. By default it replaces the values of sensitive variables with stars 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:: .. seealso::

View File

@ -158,6 +158,17 @@ Email
* The :setting:`EMAIL_FILE_PATH` setting, used by the :ref:`file email backend * The :setting:`EMAIL_FILE_PATH` setting, used by the :ref:`file email backend
<topic-email-file-backend>`, now supports :class:`pathlib.Path`. <topic-email-file-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 File Storage
~~~~~~~~~~~~ ~~~~~~~~~~~~

View File

@ -20,11 +20,13 @@ from django.test.utils import LoggingCaptureMixin
from django.urls import path, reverse from django.urls import path, reverse
from django.urls.converters import IntConverter from django.urls.converters import IntConverter
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.regex_helper import _lazy_re_compile
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.debug import ( from django.views.debug import (
CLEANSED_SUBSTITUTE, CallableSettingWrapper, ExceptionReporter, CallableSettingWrapper, ExceptionReporter, Path as DebugPath,
Path as DebugPath, cleanse_setting, default_urlconf, SafeExceptionReporterFilter, default_urlconf,
technical_404_response, technical_500_response, get_default_exception_reporter_filter, technical_404_response,
technical_500_response,
) )
from django.views.decorators.debug import ( from django.views.decorators.debug import (
sensitive_post_parameters, sensitive_variables, sensitive_post_parameters, sensitive_variables,
@ -1199,6 +1201,66 @@ class ExceptionReporterFilterTests(ExceptionReportTestMixin, LoggingCaptureMixin
response = self.client.get('/raises500/') response = self.client.get('/raises500/')
self.assertNotContains(response, 'should not be displayed', status_code=500) 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): class AjaxResponseExceptionReporterFilter(ExceptionReportTestMixin, LoggingCaptureMixin, SimpleTestCase):
""" """
@ -1262,21 +1324,6 @@ class AjaxResponseExceptionReporterFilter(ExceptionReportTestMixin, LoggingCaptu
self.assertEqual(response['Content-Type'], 'text/plain; charset=utf-8') 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): class DecoratorsTests(SimpleTestCase):
def test_sensitive_variables_not_called(self): def test_sensitive_variables_not_called(self):
msg = ( msg = (