diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 288dc9a1d9..1300e86caa 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -537,6 +537,10 @@ LOGGING = { } } +# Default exception reporter filter class used in case none has been +# specifically assigned to the HttpRequest instance. +DEFAULT_EXCEPTION_REPORTER_FILTER = 'django.views.debug.SafeExceptionReporterFilter' + ########### # TESTING # ########### diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py index 7d855d855d..237b15364e 100644 --- a/django/contrib/auth/admin.py +++ b/django/contrib/auth/admin.py @@ -12,6 +12,7 @@ from django.utils.html import escape from django.utils.decorators import method_decorator from django.utils.translation import ugettext, ugettext_lazy as _ from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters csrf_protect_m = method_decorator(csrf_protect) @@ -78,6 +79,7 @@ class UserAdmin(admin.ModelAdmin): (r'^(\d+)/password/$', self.admin_site.admin_view(self.user_change_password)) ) + super(UserAdmin, self).get_urls() + @sensitive_post_parameters() @csrf_protect_m @transaction.commit_on_success def add_view(self, request, form_url='', extra_context=None): @@ -102,6 +104,7 @@ class UserAdmin(admin.ModelAdmin): extra_context.update(defaults) return super(UserAdmin, self).add_view(request, form_url, extra_context) + @sensitive_post_parameters() def user_change_password(self, request, id): if not self.has_change_permission(request): raise PermissionDenied diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index cfb265907b..4995f89caf 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -6,6 +6,7 @@ from django.http import HttpResponseRedirect, QueryDict from django.template.response import TemplateResponse from django.utils.http import base36_to_int from django.utils.translation import ugettext as _ +from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect @@ -18,6 +19,7 @@ from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import get_current_site +@sensitive_post_parameters() @csrf_protect @never_cache def login(request, template_name='registration/login.html', @@ -175,6 +177,7 @@ def password_reset_done(request, current_app=current_app) # Doesn't need csrf_protect since no-one can guess the URL +@sensitive_post_parameters() @never_cache def password_reset_confirm(request, uidb36=None, token=None, template_name='registration/password_reset_confirm.html', @@ -227,6 +230,7 @@ def password_reset_complete(request, return TemplateResponse(request, template_name, context, current_app=current_app) +@sensitive_post_parameters() @csrf_protect @login_required def password_change(request, diff --git a/django/core/handlers/base.py b/django/core/handlers/base.py index d653860547..7cae42e651 100644 --- a/django/core/handlers/base.py +++ b/django/core/handlers/base.py @@ -206,7 +206,7 @@ class BaseHandler(object): exc_info=exc_info, extra={ 'status_code': 500, - 'request':request + 'request': request } ) diff --git a/django/utils/log.py b/django/utils/log.py index 93e38d1e4b..969a9d9609 100644 --- a/django/utils/log.py +++ b/django/utils/log.py @@ -1,6 +1,10 @@ import logging import sys +import traceback + +from django.conf import settings from django.core import mail +from django.views.debug import ExceptionReporter, get_exception_reporter_filter # Make sure a NullHandler is available # This was added in Python 2.7/3.2 @@ -35,13 +39,9 @@ class AdminEmailHandler(logging.Handler): """An exception log handler that emails log entries to site admins. If the request is passed as the first argument to the log record, - request data will be provided in the + request data will be provided in the email report. """ def emit(self, record): - import traceback - from django.conf import settings - from django.views.debug import ExceptionReporter - try: request = record.request subject = '%s (%s IP): %s' % ( @@ -49,15 +49,15 @@ class AdminEmailHandler(logging.Handler): (request.META.get('REMOTE_ADDR') in settings.INTERNAL_IPS and 'internal' or 'EXTERNAL'), record.msg ) - request_repr = repr(request) + filter = get_exception_reporter_filter(request) + request_repr = filter.get_request_repr(request) except: subject = '%s: %s' % ( record.levelname, record.msg ) - request = None - request_repr = "Request repr() unavailable" + request_repr = "Request repr() unavailable." if record.exc_info: exc_info = record.exc_info diff --git a/django/views/debug.py b/django/views/debug.py index 67f25b3465..c607f433c5 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -3,9 +3,11 @@ import os import re import sys import types +from pprint import pformat from django.conf import settings -from django.http import HttpResponse, HttpResponseServerError, HttpResponseNotFound +from django.http import (HttpResponse, HttpResponseServerError, + HttpResponseNotFound, HttpRequest) from django.template import (Template, Context, TemplateDoesNotExist, TemplateSyntaxError) from django.template.defaultfilters import force_escape, pprint @@ -15,6 +17,8 @@ from django.utils.encoding import smart_unicode, smart_str HIDDEN_SETTINGS = re.compile('SECRET|PASSWORD|PROFANITIES_LIST|SIGNATURE') +CLEANSED_SUBSTITUTE = u'********************' + def linebreak_iter(template_source): yield 0 p = template_source.find('\n') @@ -31,7 +35,7 @@ def cleanse_setting(key, value): """ try: if HIDDEN_SETTINGS.search(key): - cleansed = '********************' + cleansed = CLEANSED_SUBSTITUTE else: if isinstance(value, dict): cleansed = dict((k, cleanse_setting(k, v)) for k,v in value.items()) @@ -59,12 +63,158 @@ def technical_500_response(request, exc_type, exc_value, tb): html = reporter.get_traceback_html() return HttpResponseServerError(html, mimetype='text/html') +# Cache for the default exception reporter filter instance. +default_exception_reporter_filter = None + +def get_exception_reporter_filter(request): + global default_exception_reporter_filter + if default_exception_reporter_filter is None: + # Load the default filter for the first time and cache it. + modpath = settings.DEFAULT_EXCEPTION_REPORTER_FILTER + modname, classname = modpath.rsplit('.', 1) + try: + mod = import_module(modname) + except ImportError, e: + raise ImproperlyConfigured( + 'Error importing default exception reporter filter %s: "%s"' % (modpath, e)) + try: + default_exception_reporter_filter = getattr(mod, classname)() + except AttributeError: + raise exceptions.ImproperlyConfigured('Default exception reporter filter module "%s" does not define a "%s" class' % (modname, classname)) + if request: + return getattr(request, 'exception_reporter_filter', default_exception_reporter_filter) + else: + return default_exception_reporter_filter + +class ExceptionReporterFilter(object): + """ + Base for all exception reporter filter classes. All overridable hooks + contain lenient default behaviours. + """ + + def get_request_repr(self, request): + if request is None: + return repr(None) + else: + # Since this is called as part of error handling, we need to be very + # robust against potentially malformed input. + try: + get = pformat(request.GET) + except: + get = '' + if request._post_parse_error: + post = '' + else: + try: + post = pformat(self.get_post_parameters(request)) + except: + post = '' + try: + cookies = pformat(request.COOKIES) + except: + cookies = '' + try: + meta = pformat(request.META) + except: + meta = '' + return smart_str(u'<%s\npath:%s,\nGET:%s,\nPOST:%s,\nCOOKIES:%s,\nMETA:%s>' % + (request.__class__.__name__, + request.path, + unicode(get), + unicode(post), + unicode(cookies), + unicode(meta))) + + def get_post_parameters(self, request): + if request is None: + return {} + else: + return request.POST + + def get_traceback_frame_variables(self, request, tb_frame): + return tb_frame.f_locals.items() + +class SafeExceptionReporterFilter(ExceptionReporterFilter): + """ + Use annotations made by the sensitive_post_parameters and + sensitive_variables decorators to filter out sensitive information. + """ + + def is_active(self, request): + """ + This filter is to add safety in production environments (i.e. DEBUG + is False). If DEBUG is True then your site is not safe anyway. + This hook is provided as a convenience to easily activate or + deactivate the filter on a per request basis. + """ + return settings.DEBUG is False + + def get_post_parameters(self, request): + """ + Replaces the values of POST parameters marked as sensitive with + stars (*********). + """ + if request is None: + return {} + else: + sensitive_post_parameters = getattr(request, 'sensitive_post_parameters', []) + if self.is_active(request) and sensitive_post_parameters: + cleansed = request.POST.copy() + if sensitive_post_parameters == '__ALL__': + # Cleanse all parameters. + for k, v in cleansed.items(): + cleansed[k] = CLEANSED_SUBSTITUTE + return cleansed + else: + # Cleanse only the specified parameters. + for param in sensitive_post_parameters: + if cleansed.has_key(param): + cleansed[param] = CLEANSED_SUBSTITUTE + return cleansed + else: + return request.POST + + def get_traceback_frame_variables(self, request, tb_frame): + """ + Replaces the values of variables marked as sensitive with + stars (*********). + """ + func_name = tb_frame.f_code.co_name + func = tb_frame.f_globals.get(func_name) + sensitive_variables = getattr(func, 'sensitive_variables', []) + cleansed = [] + if self.is_active(request) and sensitive_variables: + if sensitive_variables == '__ALL__': + # Cleanse all variables + for name, value in tb_frame.f_locals.items(): + cleansed.append((name, CLEANSED_SUBSTITUTE)) + return cleansed + else: + # Cleanse specified variables + for name, value in tb_frame.f_locals.items(): + if name in sensitive_variables: + value = CLEANSED_SUBSTITUTE + elif isinstance(value, HttpRequest): + # Cleanse the request's POST parameters. + value = self.get_request_repr(value) + cleansed.append((name, value)) + return cleansed + else: + # Potentially cleanse only the request if it's one of the frame variables. + for name, value in tb_frame.f_locals.items(): + if isinstance(value, HttpRequest): + # Cleanse the request's POST parameters. + value = self.get_request_repr(value) + cleansed.append((name, value)) + return cleansed + class ExceptionReporter(object): """ A class to organize and coordinate reporting on exceptions. """ def __init__(self, request, exc_type, exc_value, tb, is_email=False): self.request = request + self.filter = get_exception_reporter_filter(self.request) self.exc_type = exc_type self.exc_value = exc_value self.tb = tb @@ -124,6 +274,7 @@ class ExceptionReporter(object): 'unicode_hint': unicode_hint, 'frames': frames, 'request': self.request, + 'filtered_POST': self.filter.get_post_parameters(self.request), 'settings': get_safe_settings(), 'sys_executable': sys.executable, 'sys_version_info': '%d.%d.%d' % sys.version_info[0:3], @@ -222,7 +373,7 @@ class ExceptionReporter(object): frames = [] tb = self.tb while tb is not None: - # support for __traceback_hide__ which is used by a few libraries + # Support for __traceback_hide__ which is used by a few libraries # to hide internal frames. if tb.tb_frame.f_locals.get('__traceback_hide__'): tb = tb.tb_next @@ -239,7 +390,7 @@ class ExceptionReporter(object): 'filename': filename, 'function': function, 'lineno': lineno + 1, - 'vars': tb.tb_frame.f_locals.items(), + 'vars': self.filter.get_traceback_frame_variables(self.request, tb.tb_frame), 'id': id(tb), 'pre_context': pre_context, 'context_line': context_line, @@ -643,7 +794,7 @@ Exception Value: {{ exception_value|force_escape }} {% endif %}

POST

- {% if request.POST %} + {% if filtered_POST %} @@ -652,7 +803,7 @@ Exception Value: {{ exception_value|force_escape }} - {% for var in request.POST.items %} + {% for var in filtered_POST.items %} diff --git a/django/views/decorators/debug.py b/django/views/decorators/debug.py new file mode 100644 index 0000000000..d04967ef09 --- /dev/null +++ b/django/views/decorators/debug.py @@ -0,0 +1,71 @@ +import functools + + +def sensitive_variables(*variables): + """ + Indicates which variables used in the decorated function are sensitive, so + that those variables can later be treated in a special way, for example + by hiding them when logging unhandled exceptions. + + Two forms are accepted: + + * with specified variable names: + + @sensitive_variables('user', 'password', 'credit_card') + def my_function(user): + password = user.pass_word + credit_card = user.credit_card_number + ... + + * without any specified variable names, in which case it is assumed that + all variables are considered sensitive: + + @sensitive_variables() + def my_function() + ... + """ + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + if variables: + wrapper.sensitive_variables = variables + else: + wrapper.sensitive_variables = '__ALL__' + return func(*args, **kwargs) + return wrapper + return decorator + + +def sensitive_post_parameters(*parameters): + """ + Indicates which POST parameters used in the decorated view are sensitive, + so that those parameters can later be treated in a special way, for example + by hiding them when logging unhandled exceptions. + + Two forms are accepted: + + * with specified parameters: + + @sensitive_post_parameters('password', 'credit_card') + def my_view(request): + pw = request.POST['password'] + cc = request.POST['credit_card'] + ... + + * without any specified parameters, in which case it is assumed that + all parameters are considered sensitive: + + @sensitive_post_parameters() + def my_view(request) + ... + """ + def decorator(view): + @functools.wraps(view) + def wrapper(request, *args, **kwargs): + if parameters: + request.sensitive_post_parameters = parameters + else: + request.sensitive_post_parameters = '__ALL__' + return view(request, *args, **kwargs) + return wrapper + return decorator diff --git a/docs/howto/error-reporting.txt b/docs/howto/error-reporting.txt index c15c1d872d..957d977364 100644 --- a/docs/howto/error-reporting.txt +++ b/docs/howto/error-reporting.txt @@ -1,5 +1,5 @@ -Error reporting via email -========================= +Error reporting +=============== When you're running a public site you should always turn off the :setting:`DEBUG` setting. That will make your server run much faster, and will @@ -9,10 +9,13 @@ revealed by the error pages. However, running with :setting:`DEBUG` set to ``False`` means you'll never see errors generated by your site -- everyone will just see your public error pages. You need to keep track of errors that occur in deployed sites, so Django can be -configured to email you details of those errors. +configured to create reports with details about those errors. + +Email reports +------------- Server errors -------------- +~~~~~~~~~~~~~ When :setting:`DEBUG` is ``False``, Django will email the users listed in the :setting:`ADMINS` setting whenever your code raises an unhandled exception and @@ -48,7 +51,7 @@ setting. `. 404 errors ----------- +~~~~~~~~~~ Django can also be configured to email errors about broken links (404 "page not found" errors). Django sends emails about 404 errors when: @@ -94,13 +97,6 @@ periods to escape them.) The best way to disable this behavior is to set :setting:`SEND_BROKEN_LINK_EMAILS` to ``False``. -.. seealso:: - - You can also set up custom error reporting by writing a custom piece of - :ref:`exception middleware `. If you do write custom - error handling, it's a good idea to emulate Django's built-in error handling - and only report/log errors if :setting:`DEBUG` is ``False``. - .. seealso:: .. versionadded:: 1.3 @@ -116,3 +112,174 @@ The best way to disable this behavior is to set Previously, two settings were used to control which URLs not to report: :setting:`IGNORABLE_404_STARTS` and :setting:`IGNORABLE_404_ENDS`. They were replaced by :setting:`IGNORABLE_404_URLS`. + +.. _filtering-error-reports: + +Filtering error reports +----------------------- + +.. versionadded:: 1.4 + +Filtering sensitive information +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error reports are really helpful for debugging errors, so it is generally +useful to record as much relevant information about those errors as possible. +For example, by default Django records the `full traceback`_ for the +exception raised, each `traceback frame`_'s local variables, and the +:class:`HttpRequest`'s :ref:`attributes`. + +However, sometimes certain types of information may be too sensitive and thus +may not be appropriate to be kept track of, for example a user's password or +credit card number. So Django offers a set of function decorators to help you +control which information should be filtered out of error reports in a +production environment (that is, where :setting:`DEBUG` is set to ``False``): +:func:`sensitive_variables` and :func:`sensitive_post_parameters`. + +.. _`full traceback`: http://en.wikipedia.org/wiki/Stack_trace +.. _`traceback frame`: http://en.wikipedia.org/wiki/Stack_frame + +.. function:: sensitive_variables(*variables) + + If a function (either a view or any regular callback) in your code uses + local variables susceptible to contain sensitive information, you may + prevent the values of those variables from being included in error reports + using the ``sensitive_variables`` decorator: + + .. code-block:: python + + from django.views.decorators.debug import sensitive_variables + + @sensitive_variables('user', 'pw', 'cc') + def process_info(user): + pw = user.pass_word + cc = user.credit_card_number + name = user.name + ... + + In the above example, the values for the ``user``, ``pw`` and ``cc`` + variables will be hidden and replaced with stars (`**********`) in the + error reports, whereas the value of the ``name`` variable will be + disclosed. + + To systematically hide all local variables of a function from error logs, + do not provide any argument to the ``sensitive_variables`` decorator: + + .. code-block:: python + + @sensitive_variables() + def my_function(): + ... + +.. function:: sensitive_post_parameters(*parameters) + + If one of your views receives an :class:`HttpRequest` object with + :attr:`POST parameters` susceptible to contain sensitive + information, you may prevent the values of those parameters from being + included in the error reports using the ``sensitive_post_parameters`` + decorator: + + .. code-block:: python + + from django.views.decorators.debug import sensitive_post_parameters + + @sensitive_post_parameters('pass_word', 'credit_card_number') + def record_user_profile(request): + UserProfile.create(user=request.user, + password=request.POST['pass_word'], + credit_card=request.POST['credit_card_number'], + name=request.POST['name']) + ... + + In the above example, the values for the ``pass_word`` and + ``credit_card_number`` POST parameters will be hidden and replaced with + stars (`**********`) in the request's representation inside the error + reports, whereas the value of the ``name`` parameter will be disclosed. + + To systematically hide all POST parameters of a request in error reports, + do not provide any argument to the ``sensitive_post_parameters`` decorator: + + .. code-block:: python + + @sensitive_post_parameters() + def my_view(request): + ... + +.. note:: + + .. versionchanged:: 1.4 + + Since version 1.4, all POST parameters are systematically filtered out of + error reports for certain :mod:`contrib.views.auth` views (``login``, + ``password_reset_confirm``, ``password_change``, and ``add_view`` and + ``user_change_password`` in the ``auth`` admin) to prevent the leaking of + sensitive information such as user passwords. + +.. _custom-error-reports: + +Custom error reports +~~~~~~~~~~~~~~~~~~~~ + +All :func:`sensitive_variables` and :func:`sensitive_post_parameters` do is, +respectively, annotate the decorated function with the names of sensitive +variables and annotate the ``HttpRequest`` object with the names of sensitive +POST parameters, so that this sensitive information can later be filtered out +of reports when an error occurs. The actual filtering is done by Django's +default error reporter filter: +:class:`django.views.debug.SafeExceptionReporterFilter`. This filter uses the +decorators' annotations to replace the corresponding values with stars +(`**********`) when the error reports are produced. If you wish to override or +customize this default behavior for your entire site, you need to define your +own filter class and tell Django to use it via the +:setting:`DEFAULT_EXCEPTION_REPORTER_FILTER` setting: + + .. code-block:: python + + DEFAULT_EXCEPTION_REPORTER_FILTER = 'path.to.your.CustomExceptionReporterFilter' + +You may also control in a more granular way which filter to use within any +given view by setting the ``HttpRequest``'s ``exception_reporter_filter`` +attribute: + + .. code-block:: python + + def my_view(request): + if request.user.is_authenticated(): + request.exception_reporter_filter = CustomExceptionReporterFilter() + ... + +Your custom filter class needs to inherit from +:class:`django.views.debug.SafeExceptionReporterFilter` and may override the +following methods: + +.. class:: django.views.debug.SafeExceptionReporterFilter + +.. method:: SafeExceptionReporterFilter.is_active(self, request) + + Returns ``True`` to activate the filtering operated in the other methods. + By default the filter is active if :setting:`DEBUG` is ``False``. + +.. method:: SafeExceptionReporterFilter.get_request_repr(self, request) + + Returns the representation string of the request object, that is, the + value that would be returned by ``repr(request)``, except it uses the + filtered dictionary of POST parameters as determined by + :meth:`SafeExceptionReporterFilter.get_post_parameters`. + +.. method:: SafeExceptionReporterFilter.get_post_parameters(self, request) + + Returns the filtered dictionary of POST parameters. By default it replaces + the values of sensitive parameters with stars (`**********`). + +.. method:: SafeExceptionReporterFilter.get_traceback_frame_variables(self, request, tb_frame) + + Returns the filtered dictionary of local variables for the given traceback + frame. By default it replaces the values of sensitive variables with stars + (`**********`). + +.. seealso:: + + You can also set up custom error reporting by writing a custom piece of + :ref:`exception middleware `. If you do write custom + error handling, it's a good idea to emulate Django's built-in error handling + and only report/log errors if :setting:`DEBUG` is ``False``. diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 72872d5b40..ff04e25b1d 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -23,6 +23,8 @@ HttpRequest objects .. class:: HttpRequest +.. _httprequest-attributes: + Attributes ---------- diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 816c3e92ff..e95c165a9a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -772,6 +772,17 @@ Default content type to use for all ``HttpResponse`` objects, if a MIME type isn't manually specified. Used with :setting:`DEFAULT_CHARSET` to construct the ``Content-Type`` header. +.. setting:: DEFAULT_EXCEPTION_REPORTER_FILTER + +DEFAULT_EXCEPTION_REPORTER_FILTER +--------------------------------- + +Default: :class:`django.views.debug.SafeExceptionReporterFilter` + +Default exception reporter filter class to be used if none has been assigned to +the :class:`HttpRequest` instance yet. +See :ref:`Filtering error reports`. + .. setting:: DEFAULT_FILE_STORAGE DEFAULT_FILE_STORAGE diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 83ebaa738b..ddbcda9e60 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -116,6 +116,26 @@ help with AJAX heavy sites, protection for PUT and DELETE, and settings the security and usefulness of the CSRF protection. See the :doc:`CSRF docs ` for more information. +Error report filtering +~~~~~~~~~~~~~~~~~~~~~~ + +Two new function decorators, :func:`sensitive_variables` and +:func:`sensitive_post_parameters`, were added to allow designating the +traceback frames' local variables and request's POST parameters susceptible +to contain sensitive information and that should be filtered out of error +reports. + +All POST parameters are now systematically filtered out of error reports for +certain :mod:`contrib.views.auth` views (``login``, ``password_reset_confirm``, +``password_change``, and ``add_view`` and ``user_change_password`` in the +``auth`` admin) to prevent the leaking of sensitive information such as user +passwords. + +You may override or customize the default filtering by writing a +:ref:`custom filter`. Learn more on +:ref:`Filtering error reports`. + + .. _backwards-incompatible-changes-1.4: Backwards incompatible changes in 1.4 diff --git a/docs/topics/logging.txt b/docs/topics/logging.txt index 651a92ff34..e2c9f72ea6 100644 --- a/docs/topics/logging.txt +++ b/docs/topics/logging.txt @@ -504,6 +504,8 @@ Python logging module. sensitive, and you may not want to send it over email. Consider using something such as `django-sentry`_ to get the best of both worlds -- the rich information of full tracebacks plus the security of *not* sending the - information over email. + information over email. You may also explicitly designate certain + sensitive information to be filtered out of error reports -- learn more on + :ref:`Filtering error reports`. .. _django-sentry: http://pypi.python.org/pypi/django-sentry diff --git a/tests/regressiontests/views/tests/debug.py b/tests/regressiontests/views/tests/debug.py index 6dd4bd4934..1dbaff490f 100644 --- a/tests/regressiontests/views/tests/debug.py +++ b/tests/regressiontests/views/tests/debug.py @@ -1,3 +1,4 @@ +from __future__ import with_statement import inspect import os import sys @@ -8,8 +9,12 @@ from django.test import TestCase, RequestFactory from django.core.urlresolvers import reverse from django.template import TemplateSyntaxError from django.views.debug import ExceptionReporter +from django.core.exceptions import ImproperlyConfigured +from django.core import mail from regressiontests.views import BrokenException, except_args +from regressiontests.views.views import (sensitive_view, non_sensitive_view, + paranoid_view, custom_exception_reporter_filter_view) class DebugViewTests(TestCase): @@ -143,3 +148,188 @@ class ExceptionReporterTests(TestCase): self.assertNotIn('

Traceback ', html) self.assertIn('

Request information

', html) self.assertIn('

Request data not supplied

', html) + + +class ExceptionReporterFilterTests(TestCase): + """ + Ensure that sensitive information can be filtered out of error reports. + Refs #14614. + """ + rf = RequestFactory() + breakfast_data = {'sausage-key': 'sausage-value', + 'baked-beans-key': 'baked-beans-value', + 'hash-brown-key': 'hash-brown-value', + 'bacon-key': 'bacon-value',} + + def verify_unsafe_response(self, view): + """ + Asserts that potentially sensitive info are displayed in the response. + """ + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + # All variables are shown. + self.assertContains(response, 'cooked_eggs', status_code=500) + self.assertContains(response, 'scrambled', status_code=500) + self.assertContains(response, 'sauce', status_code=500) + self.assertContains(response, 'worcestershire', status_code=500) + for k, v in self.breakfast_data.items(): + # All POST parameters are shown. + self.assertContains(response, k, status_code=500) + self.assertContains(response, v, status_code=500) + + def verify_safe_response(self, view): + """ + Asserts that certain sensitive info are not displayed in the response. + """ + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + # Non-sensitive variable's name and value are shown. + self.assertContains(response, 'cooked_eggs', status_code=500) + self.assertContains(response, 'scrambled', status_code=500) + # Sensitive variable's name is shown but not its value. + self.assertContains(response, 'sauce', status_code=500) + self.assertNotContains(response, 'worcestershire', status_code=500) + for k, v in self.breakfast_data.items(): + # All POST parameters' names are shown. + self.assertContains(response, k, status_code=500) + # Non-sensitive POST parameters' values are shown. + self.assertContains(response, 'baked-beans-value', status_code=500) + self.assertContains(response, 'hash-brown-value', status_code=500) + # Sensitive POST parameters' values are not shown. + self.assertNotContains(response, 'sausage-value', status_code=500) + self.assertNotContains(response, 'bacon-value', status_code=500) + + def verify_paranoid_response(self, view): + """ + Asserts that no variables or POST parameters are displayed in the response. + """ + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + # Show variable names but not their values. + self.assertContains(response, 'cooked_eggs', status_code=500) + self.assertNotContains(response, 'scrambled', status_code=500) + self.assertContains(response, 'sauce', status_code=500) + self.assertNotContains(response, 'worcestershire', status_code=500) + for k, v in self.breakfast_data.items(): + # All POST parameters' names are shown. + self.assertContains(response, k, status_code=500) + # No POST parameters' values are shown. + self.assertNotContains(response, v, status_code=500) + + def verify_unsafe_email(self, view): + """ + Asserts that potentially sensitive info are displayed in the email report. + """ + with self.settings(ADMINS=(('Admin', 'admin@fattie-breakie.com'),)): + mail.outbox = [] # Empty outbox + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + self.assertEquals(len(mail.outbox), 1) + email = mail.outbox[0] + # Frames vars are never shown in plain text email reports. + self.assertNotIn('cooked_eggs', email.body) + self.assertNotIn('scrambled', email.body) + self.assertNotIn('sauce', email.body) + self.assertNotIn('worcestershire', email.body) + for k, v in self.breakfast_data.items(): + # All POST parameters are shown. + self.assertIn(k, email.body) + self.assertIn(v, email.body) + + def verify_safe_email(self, view): + """ + Asserts that certain sensitive info are not displayed in the email report. + """ + with self.settings(ADMINS=(('Admin', 'admin@fattie-breakie.com'),)): + mail.outbox = [] # Empty outbox + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + self.assertEquals(len(mail.outbox), 1) + email = mail.outbox[0] + # Frames vars are never shown in plain text email reports. + self.assertNotIn('cooked_eggs', email.body) + self.assertNotIn('scrambled', email.body) + self.assertNotIn('sauce', email.body) + self.assertNotIn('worcestershire', email.body) + for k, v in self.breakfast_data.items(): + # All POST parameters' names are shown. + self.assertIn(k, email.body) + # Non-sensitive POST parameters' values are shown. + self.assertIn('baked-beans-value', email.body) + self.assertIn('hash-brown-value', email.body) + # Sensitive POST parameters' values are not shown. + self.assertNotIn('sausage-value', email.body) + self.assertNotIn('bacon-value', email.body) + + def verify_paranoid_email(self, view): + """ + Asserts that no variables or POST parameters are displayed in the email report. + """ + with self.settings(ADMINS=(('Admin', 'admin@fattie-breakie.com'),)): + mail.outbox = [] # Empty outbox + request = self.rf.post('/some_url/', self.breakfast_data) + response = view(request) + self.assertEquals(len(mail.outbox), 1) + email = mail.outbox[0] + # Frames vars are never shown in plain text email reports. + self.assertNotIn('cooked_eggs', email.body) + self.assertNotIn('scrambled', email.body) + self.assertNotIn('sauce', email.body) + self.assertNotIn('worcestershire', email.body) + for k, v in self.breakfast_data.items(): + # All POST parameters' names are shown. + self.assertIn(k, email.body) + # No POST parameters' values are shown. + self.assertNotIn(v, email.body) + + def test_non_sensitive_request(self): + """ + Ensure that everything (request info and frame variables) can bee seen + in the default error reports for non-sensitive requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(non_sensitive_view) + self.verify_unsafe_email(non_sensitive_view) + + with self.settings(DEBUG=False): + self.verify_unsafe_response(non_sensitive_view) + self.verify_unsafe_email(non_sensitive_view) + + def test_sensitive_request(self): + """ + Ensure that sensitive POST parameters and frame variables cannot be + seen in the default error reports for sensitive requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(sensitive_view) + self.verify_unsafe_email(sensitive_view) + + with self.settings(DEBUG=False): + self.verify_safe_response(sensitive_view) + self.verify_safe_email(sensitive_view) + + def test_paranoid_request(self): + """ + Ensure that no POST parameters and frame variables can be seen in the + default error reports for "paranoid" requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(paranoid_view) + self.verify_unsafe_email(paranoid_view) + + with self.settings(DEBUG=False): + self.verify_paranoid_response(paranoid_view) + self.verify_paranoid_email(paranoid_view) + + def test_custom_exception_reporter_filter(self): + """ + Ensure that it's possible to assign an exception reporter filter to + the request to bypass the one set in DEFAULT_EXCEPTION_REPORTER_FILTER. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(custom_exception_reporter_filter_view) + self.verify_unsafe_email(custom_exception_reporter_filter_view) + + with self.settings(DEBUG=False): + self.verify_unsafe_response(custom_exception_reporter_filter_view) + self.verify_unsafe_email(custom_exception_reporter_filter_view) diff --git a/tests/regressiontests/views/views.py b/tests/regressiontests/views/views.py index 11d289f2bb..732ff280ca 100644 --- a/tests/regressiontests/views/views.py +++ b/tests/regressiontests/views/views.py @@ -5,7 +5,10 @@ from django.http import HttpResponse, HttpResponseRedirect from django.core.urlresolvers import get_resolver from django.shortcuts import render_to_response, render from django.template import Context, RequestContext, TemplateDoesNotExist -from django.views.debug import technical_500_response +from django.views.debug import technical_500_response, SafeExceptionReporterFilter +from django.views.decorators.debug import (sensitive_post_parameters, + sensitive_variables) +from django.utils.log import getLogger from regressiontests.views import BrokenException, except_args @@ -128,3 +131,84 @@ def raises_template_does_not_exist(request): return render_to_response('i_dont_exist.html') except TemplateDoesNotExist: return technical_500_response(request, *sys.exc_info()) + +def send_log(request, exc_info): + logger = getLogger('django.request') + logger.error('Internal Server Error: %s' % request.path, + exc_info=exc_info, + extra={ + 'status_code': 500, + 'request': request + } + ) + +def non_sensitive_view(request): + # Do not just use plain strings for the variables' values in the code + # so that the tests don't return false positives when the function's source + # is displayed in the exception report. + cooked_eggs = ''.join(['s', 'c', 'r', 'a', 'm', 'b', 'l', 'e', 'd']) + sauce = ''.join(['w', 'o', 'r', 'c', 'e', 's', 't', 'e', 'r', 's', 'h', 'i', 'r', 'e']) + try: + raise Exception + except Exception: + exc_info = sys.exc_info() + send_log(request, exc_info) + return technical_500_response(request, *exc_info) + +@sensitive_variables('sauce') +@sensitive_post_parameters('bacon-key', 'sausage-key') +def sensitive_view(request): + # Do not just use plain strings for the variables' values in the code + # so that the tests don't return false positives when the function's source + # is displayed in the exception report. + cooked_eggs = ''.join(['s', 'c', 'r', 'a', 'm', 'b', 'l', 'e', 'd']) + sauce = ''.join(['w', 'o', 'r', 'c', 'e', 's', 't', 'e', 'r', 's', 'h', 'i', 'r', 'e']) + try: + raise Exception + except Exception: + exc_info = sys.exc_info() + send_log(request, exc_info) + return technical_500_response(request, *exc_info) + +@sensitive_variables() +@sensitive_post_parameters() +def paranoid_view(request): + # Do not just use plain strings for the variables' values in the code + # so that the tests don't return false positives when the function's source + # is displayed in the exception report. + cooked_eggs = ''.join(['s', 'c', 'r', 'a', 'm', 'b', 'l', 'e', 'd']) + sauce = ''.join(['w', 'o', 'r', 'c', 'e', 's', 't', 'e', 'r', 's', 'h', 'i', 'r', 'e']) + try: + raise Exception + except Exception: + exc_info = sys.exc_info() + send_log(request, exc_info) + return technical_500_response(request, *exc_info) + +class UnsafeExceptionReporterFilter(SafeExceptionReporterFilter): + """ + Ignores all the filtering done by its parent class. + """ + + def get_post_parameters(self, request): + return request.POST + + def get_traceback_frame_variables(self, request, tb_frame): + return tb_frame.f_locals.items() + + +@sensitive_variables() +@sensitive_post_parameters() +def custom_exception_reporter_filter_view(request): + # Do not just use plain strings for the variables' values in the code + # so that the tests don't return false positives when the function's source + # is displayed in the exception report. + cooked_eggs = ''.join(['s', 'c', 'r', 'a', 'm', 'b', 'l', 'e', 'd']) + sauce = ''.join(['w', 'o', 'r', 'c', 'e', 's', 't', 'e', 'r', 's', 'h', 'i', 'r', 'e']) + request.exception_reporter_filter = UnsafeExceptionReporterFilter() + try: + raise Exception + except Exception: + exc_info = sys.exc_info() + send_log(request, exc_info) + return technical_500_response(request, *exc_info)
{{ var.0 }}
{{ var.1|pprint }}