diff --git a/django/views/debug.py b/django/views/debug.py --- a/django/views/debug.py +++ b/django/views/debug.py @@ -59,8 +59,12 @@ the values returned from sys.exc_info() and friends. """ reporter = ExceptionReporter(request, exc_type, exc_value, tb) - html = reporter.get_traceback_html() - return HttpResponseServerError(html, mimetype='text/html') + if request.is_ajax(): + text = reporter.get_traceback_text() + return HttpResponseServerError(text, mimetype='text/plain') + else: + html = reporter.get_traceback_html() + return HttpResponseServerError(html, mimetype='text/html') # Cache for the default exception reporter filter instance. default_exception_reporter_filter = None @@ -201,8 +205,8 @@ self.exc_value = Exception('Deprecated String Exception: %r' % self.exc_type) self.exc_type = type(self.exc_value) - def get_traceback_html(self): - "Return HTML code for traceback." + def get_traceback_data(self): + "Return a Context instance containing traceback information." if self.exc_type and issubclass(self.exc_type, TemplateDoesNotExist): from django.template.loader import template_source_loaders @@ -240,8 +244,7 @@ unicode_str = self.exc_value.args[1] unicode_hint = smart_unicode(unicode_str[max(start-5, 0):min(end+5, len(unicode_str))], 'ascii', errors='replace') from django import get_version - t = Template(TECHNICAL_500_TEMPLATE, name='Technical 500 template') - c = Context({ + c = { 'is_email': self.is_email, 'unicode_hint': unicode_hint, 'frames': frames, @@ -256,7 +259,7 @@ 'template_info': self.template_info, 'template_does_not_exist': self.template_does_not_exist, 'loader_debug_info': self.loader_debug_info, - }) + } # Check whether exception info is available if self.exc_type: c['exception_type'] = self.exc_type.__name__ @@ -264,6 +267,18 @@ c['exception_value'] = smart_unicode(self.exc_value, errors='replace') if frames: c['lastframe'] = frames[-1] + return c + + def get_traceback_html(self): + "Return HTML version of debug 500 HTTP error page." + t = Template(TECHNICAL_500_TEMPLATE, name='Technical 500 template') + c = Context(self.get_traceback_data()) + return t.render(c) + + def get_traceback_text(self): + "Return plain text version of debug 500 HTTP error page." + t = Template(TECHNICAL_500_TEXT_TEMPLATE, name='Technical 500 template') + c = Context(self.get_traceback_data(), autoescape=False) return t.render(c) def get_template_exception_info(self): @@ -890,6 +905,67 @@ """ +TECHNICAL_500_TEXT_TEMPLATE = """{% firstof exception_type 'Report' %}{% if request %} at {{ request.path_info }}{% endif %} +{% firstof exception_value 'No exception supplied' %} +{% if request %} +Request Method: {{ request.META.REQUEST_METHOD }} +Request URL: {{ request.build_absolute_uri }}{% endif %} +Django Version: {{ django_version_info }} +Python Executable: {{ sys_executable }} +Python Version: {{ sys_version_info }} +Python Path: {{ sys_path }} +Server time: {{server_time|date:"r"}} +Installed Applications: +{{ settings.INSTALLED_APPS|pprint }} +Installed Middleware: +{{ settings.MIDDLEWARE_CLASSES|pprint }} +{% if template_does_not_exist %}Template loader Error: +{% if loader_debug_info %}Django tried loading these templates, in this order: +{% for loader in loader_debug_info %}Using loader {{ loader.loader }}: +{% for t in loader.templates %}{{ t.name }} (File {% if t.exists %}exists{% else %}does not exist{% endif %}) +{% endfor %}{% endfor %} +{% else %}Django couldn't find any templates because your TEMPLATE_LOADERS setting is empty! +{% endif %} +{% endif %}{% if template_info %} +Template error: +In template {{ template_info.name }}, error at line {{ template_info.line }} + {{ template_info.message }}{% for source_line in template_info.source_lines %}{% ifequal source_line.0 template_info.line %} + {{ source_line.0 }} : {{ template_info.before }} {{ template_info.during }} {{ template_info.after }} +{% else %} + {{ source_line.0 }} : {{ source_line.1 }} + {% endifequal %}{% endfor %}{% endif %}{% if frames %} +Traceback: +{% for frame in frames %}File "{{ frame.filename }}" in {{ frame.function }} +{% if frame.context_line %} {{ frame.lineno }}. {{ frame.context_line }}{% endif %} +{% endfor %} +{% if exception_type %}Exception Type: {{ exception_type }}{% if request %} at {{ request.path_info }}{% endif %} +{% if exception_value %}Exception Value: {{ exception_value }}{% endif %}{% endif %}{% endif %} +{% if request %}Request information: +GET:{% for k, v in request.GET.items %} +{{ k }} = {{ v|stringformat:"r" }}{% empty %} No GET data{% endfor %} + +POST:{% for k, v in filtered_POST.items %} +{{ k }} = {{ v|stringformat:"r" }}{% empty %} No POST data{% endfor %} + +FILES:{% for k, v in request.FILES.items %} +{{ k }} = {{ v|stringformat:"r" }}{% empty %} No FILES data{% endfor %} + +COOKIES:{% for k, v in request.COOKIES.items %} +{{ k }} = {{ v|stringformat:"r" }}{% empty %} No cookie data{% endfor %} + +META:{% for k, v in request.META.items|dictsort:"0" %} +{{ k }} = {{ v|stringformat:"r" }}{% endfor %} +{% else %}Request data not supplied +{% endif %} +Settings: +Using settings module {{ settings.SETTINGS_MODULE }}{% for k, v in settings.items|dictsort:"0" %} +{{ k }} = {{ v|stringformat:"r" }}{% endfor %} + +You're seeing this error because you have DEBUG = True in your +Django settings file. Change that to False, and Django will +display a standard 500 page. +""" + TECHNICAL_404_TEMPLATE = """ diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -315,6 +315,17 @@ be able to retrieve a translation string without displaying it but setting a template context variable instead. +* A new plaintext version of the HTTP 500 status code internal error page + served when :setting:`DEBUG` is ``True`` is now sent to the client when + Django detect that the request has originated in JavaScript code + (:meth:`~django.http.HttpRequest.is_ajax` is used for this). + + Similarly to its HTML counterpart, it contains a collection of different + pieces of useful information about the state of the web application. + + This should make it easier to read when debugging interaction with + client-side Javascript code. + .. _backwards-incompatible-changes-1.4: Backwards incompatible changes in 1.4 diff --git a/tests/regressiontests/views/tests/debug.py b/tests/regressiontests/views/tests/debug.py --- a/tests/regressiontests/views/tests/debug.py +++ b/tests/regressiontests/views/tests/debug.py @@ -171,45 +171,104 @@ self.assertIn('

Request data not supplied

', html) -class ExceptionReporterFilterTests(TestCase): - """ - Ensure that sensitive information can be filtered out of error reports. - Refs #14614. - """ +class PlainTextReportTests(TestCase): rf = RequestFactory() + + def test_request_and_exception(self): + "A simple exception report can be generated" + try: + request = self.rf.get('/test_view/') + raise ValueError("Can't find my keys") + except ValueError: + exc_type, exc_value, tb = sys.exc_info() + reporter = ExceptionReporter(request, exc_type, exc_value, tb) + text = reporter.get_traceback_text() + self.assertIn('ValueError at /test_view/', text) + self.assertIn("Can't find my keys", text) + self.assertIn('Request Method:', text) + self.assertIn('Request URL:', text) + self.assertIn('Exception Type:', text) + self.assertIn('Exception Value:', text) + self.assertIn('Traceback:', text) + self.assertIn('Request information:', text) + self.assertNotIn('Request data not supplied', text) + + def test_no_request(self): + "An exception report can be generated without request" + try: + raise ValueError("Can't find my keys") + except ValueError: + exc_type, exc_value, tb = sys.exc_info() + reporter = ExceptionReporter(None, exc_type, exc_value, tb) + text = reporter.get_traceback_text() + self.assertIn('ValueError', text) + self.assertIn("Can't find my keys", text) + self.assertNotIn('Request Method:', text) + self.assertNotIn('Request URL:', text) + self.assertIn('Exception Type:', text) + self.assertIn('Exception Value:', text) + self.assertIn('Traceback:', text) + self.assertIn('Request data not supplied', text) + + def test_no_exception(self): + "An exception report can be generated for just a request" + request = self.rf.get('/test_view/') + reporter = ExceptionReporter(request, None, None, None) + text = reporter.get_traceback_text() + + def test_request_and_message(self): + "A message can be provided in addition to a request" + request = self.rf.get('/test_view/') + reporter = ExceptionReporter(request, None, "I'm a little teapot", None) + text = reporter.get_traceback_text() + + def test_message_only(self): + reporter = ExceptionReporter(None, None, "I'm a little teapot", None) + text = reporter.get_traceback_text() + + +class ExceptionReportTestMixin(object): + + # Mixin used in the ExceptionReporterFilterTests and + # AjaxResponseExceptionReporterFilter tests below + 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): + def verify_unsafe_response(self, view, check_for_vars=True): """ 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) + if check_for_vars: + # 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): + def verify_safe_response(self, view, check_for_vars=True): """ 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) + if check_for_vars: + # 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) @@ -220,17 +279,19 @@ self.assertNotContains(response, 'sausage-value', status_code=500) self.assertNotContains(response, 'bacon-value', status_code=500) - def verify_paranoid_response(self, view): + def verify_paranoid_response(self, view, check_for_vars=True): """ 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) + if check_for_vars: + # 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) @@ -303,6 +364,14 @@ # No POST parameters' values are shown. self.assertNotIn(v, email.body) + +class ExceptionReporterFilterTests(TestCase, ExceptionReportTestMixin): + """ + Ensure that sensitive information can be filtered out of error reports. + Refs #14614. + """ + rf = RequestFactory() + def test_non_sensitive_request(self): """ Ensure that everything (request info and frame variables) can bee seen @@ -354,3 +423,62 @@ with self.settings(DEBUG=False): self.verify_unsafe_response(custom_exception_reporter_filter_view) self.verify_unsafe_email(custom_exception_reporter_filter_view) + + +class AjaxResponseExceptionReporterFilter(TestCase, ExceptionReportTestMixin): + """ + Ensure that sensitive information can be filtered out of error reports. + + Here we specifically test the plain text 500 error page generated when it + has been detected the request was sent by JS code. We don't check + (non)existence of views variables in the traceback information section of + the response content because they aren't included in these error pages. + Refs #14614. + """ + rf = RequestFactory(HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + def test_non_sensitive_request(self): + """ + Ensure that request info can bee seen in the default error reports for + non-sensitive requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(non_sensitive_view, check_for_vars=False) + + with self.settings(DEBUG=False): + self.verify_unsafe_response(non_sensitive_view, check_for_vars=False) + + def test_sensitive_request(self): + """ + Ensure that sensitive POST parameters cannot be seen in the default + error reports for sensitive requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(sensitive_view, check_for_vars=False) + + with self.settings(DEBUG=False): + self.verify_safe_response(sensitive_view, check_for_vars=False) + + def test_paranoid_request(self): + """ + Ensure that no POST parameters can be seen in the default error reports + for "paranoid" requests. + """ + with self.settings(DEBUG=True): + self.verify_unsafe_response(paranoid_view, check_for_vars=False) + + with self.settings(DEBUG=False): + self.verify_paranoid_response(paranoid_view, check_for_vars=False) + + 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, + check_for_vars=False) + + with self.settings(DEBUG=False): + self.verify_unsafe_response(custom_exception_reporter_filter_view, + check_for_vars=False)