diff --git a/debian/changelog b/debian/changelog index 472d500fb..e77a81a21 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,18 @@ +python-django (1:1.10.7-2+deb9u2) stretch-security; urgency=high + + * Non-maintainer upload by the Security Team. + * CVE-2018-14574: Fix an open redirect possibility in CommonMiddleware. + If the django.middleware.common.CommonMiddleware and the APPEND_SLASH + setting were both enabled, and if the project has a URL pattern that + accepted any path ending in a slash then a request to a maliciously crafted + URL of that site could lead to a redirect to another site, enabling + phishing and other attacks. (Closes: #905216) + * CVE-2017-12794: Fix a cross-site scripting attack in the technical HTTP 500 + page. This vulnerability did not affect production sites as they typically + do not run with "DEBUG = True". (Closes: #874415) + + -- Chris Lamb Thu, 02 Aug 2018 10:37:28 +0800 + python-django (1:1.10.7-2+deb9u1) stretch-security; urgency=high * Non-maintainer upload by the LTS Team. diff --git a/debian/patches/0015-CVE-2018-14574.patch b/debian/patches/0015-CVE-2018-14574.patch new file mode 100644 index 000000000..c8bf439e9 --- /dev/null +++ b/debian/patches/0015-CVE-2018-14574.patch @@ -0,0 +1,153 @@ +From: Chris Lamb +Date: Thu, 2 Aug 2018 10:28:56 +0800 +Subject: CVE-2018-14574 + +Open redirect possibility in CommonMiddleware + +If the django.middleware.common.CommonMiddleware and the APPEND_SLASH setting +are both enabled, and if the project has a URL pattern that accepts any path +ending in a slash (many content management systems have such a pattern), then a +request to a maliciously crafted URL of that site could lead to a redirect to +another site, enabling phishing and other attacks. + +Thanks Andreas Hug for reporting this issue. + + -- + +Backported by Chris Lamb from: + + https://github.com/django/django/commit/d6eaee092709aad477a9894598496c6deec532ff +--- + django/middleware/common.py | 3 +++ + django/urls/resolvers.py | 8 ++++---- + django/utils/http.py | 11 +++++++++++ + tests/middleware/tests.py | 19 +++++++++++++++++++ + tests/middleware/urls.py | 2 ++ + tests/utils_tests/test_http.py | 10 ++++++++++ + 6 files changed, 49 insertions(+), 4 deletions(-) + +diff --git a/django/middleware/common.py b/django/middleware/common.py +index 4cec6f0..4ac5e01 100644 +--- a/django/middleware/common.py ++++ b/django/middleware/common.py +@@ -9,6 +9,7 @@ from django.urls import is_valid_path + from django.utils.cache import get_conditional_response, set_response_etag + from django.utils.deprecation import MiddlewareMixin + from django.utils.encoding import force_text ++from django.utils.http import escape_leading_slashes + from django.utils.http import unquote_etag + from django.utils.six.moves.urllib.parse import urlparse + +@@ -90,6 +91,8 @@ class CommonMiddleware(MiddlewareMixin): + POST, PUT, or PATCH. + """ + new_path = request.get_full_path(force_append_slash=True) ++ # Prevent construction of scheme relative urls. ++ new_path = escape_leading_slashes(new_path) + if settings.DEBUG and request.method in ('POST', 'PUT', 'PATCH'): + raise RuntimeError( + "You called this URL via %(method)s, but the URL doesn't end " +diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py +index cec960d..da82d56 100644 +--- a/django/urls/resolvers.py ++++ b/django/urls/resolvers.py +@@ -18,7 +18,9 @@ from django.utils import lru_cache, six + from django.utils.datastructures import MultiValueDict + from django.utils.encoding import force_str, force_text + from django.utils.functional import cached_property +-from django.utils.http import RFC3986_SUBDELIMS, urlquote ++from django.utils.http import ( ++ RFC3986_SUBDELIMS, escape_leading_slashes, urlquote, ++) + from django.utils.regex_helper import normalize + from django.utils.translation import get_language + +@@ -373,9 +375,7 @@ class RegexURLResolver(LocaleRegexProvider): + # safe characters from `pchar` definition of RFC 3986 + url = urlquote(candidate_pat % candidate_subs, safe=RFC3986_SUBDELIMS + str('/~:@')) + # Don't allow construction of scheme relative urls. +- if url.startswith('//'): +- url = '/%%2F%s' % url[2:] +- return url ++ return escape_leading_slashes(url) + # lookup_view can be URL name or callable, but callables are not + # friendly in error messages. + m = getattr(lookup_view, '__module__', None) +diff --git a/django/utils/http.py b/django/utils/http.py +index 812ddb2..3898331 100644 +--- a/django/utils/http.py ++++ b/django/utils/http.py +@@ -437,3 +437,14 @@ def limited_parse_qsl(qs, keep_blank_values=False, encoding='utf-8', + value = unquote(nv[1].replace(b'+', b' ')) + r.append((name, value)) + return r ++ ++ ++def escape_leading_slashes(url): ++ """ ++ If redirecting to an absolute path (two leading slashes), a slash must be ++ escaped to prevent browsers from handling the path as schemaless and ++ redirecting to another host. ++ """ ++ if url.startswith('//'): ++ url = '/%2F{}'.format(url[2:]) ++ return url +diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py +index f87bb9d..0120529 100644 +--- a/tests/middleware/tests.py ++++ b/tests/middleware/tests.py +@@ -122,6 +122,25 @@ class CommonMiddlewareTest(SimpleTestCase): + self.assertEqual(r.status_code, 301) + self.assertEqual(r.url, '/needsquoting%23/') + ++ @override_settings(APPEND_SLASH=True) ++ def test_append_slash_leading_slashes(self): ++ """ ++ Paths starting with two slashes are escaped to prevent open redirects. ++ If there's a URL pattern that allows paths to start with two slashes, a ++ request with path //evil.com must not redirect to //evil.com/ (appended ++ slash) which is a schemaless absolute URL. The browser would navigate ++ to evil.com/. ++ """ ++ # Use 4 slashes because of RequestFactory behavior. ++ request = self.rf.get('////evil.com/security') ++ response = HttpResponseNotFound() ++ r = CommonMiddleware().process_request(request) ++ self.assertEqual(r.status_code, 301) ++ self.assertEqual(r.url, '/%2Fevil.com/security/') ++ r = CommonMiddleware().process_response(request, response) ++ self.assertEqual(r.status_code, 301) ++ self.assertEqual(r.url, '/%2Fevil.com/security/') ++ + @override_settings(APPEND_SLASH=False, PREPEND_WWW=True) + def test_prepend_www(self): + request = self.rf.get('/path/') +diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py +index 8c6621d..d623e7d 100644 +--- a/tests/middleware/urls.py ++++ b/tests/middleware/urls.py +@@ -6,4 +6,6 @@ urlpatterns = [ + url(r'^noslash$', views.empty_view), + url(r'^slash/$', views.empty_view), + url(r'^needsquoting#/$', views.empty_view), ++ # Accepts paths with two leading slashes. ++ url(r'^(.+)/security/$', views.empty_view), + ] +diff --git a/tests/utils_tests/test_http.py b/tests/utils_tests/test_http.py +index efe6b9a..ed4a099 100644 +--- a/tests/utils_tests/test_http.py ++++ b/tests/utils_tests/test_http.py +@@ -211,3 +211,13 @@ class HttpDateProcessingTests(unittest.TestCase): + def test_parsing_asctime(self): + parsed = http.parse_http_date('Sun Nov 6 08:49:37 1994') + self.assertEqual(datetime.utcfromtimestamp(parsed), datetime(1994, 11, 6, 8, 49, 37)) ++ ++ ++class EscapeLeadingSlashesTests(unittest.TestCase): ++ def test(self): ++ tests = ( ++ ('//example.com', '/%2Fexample.com'), ++ ('//', '/%2F'), ++ ) ++ for url, expected in tests: ++ self.assertEqual(http.escape_leading_slashes(url), expected) diff --git a/debian/patches/0016-CVE-2017-12794.patch b/debian/patches/0016-CVE-2017-12794.patch new file mode 100644 index 000000000..d472183db --- /dev/null +++ b/debian/patches/0016-CVE-2017-12794.patch @@ -0,0 +1,144 @@ +From: Chris Lamb +Date: Fri, 3 Aug 2018 11:48:27 +0800 +Subject: CVE-2017-12794 + +Fix a cross-site scripting attack in the technical HTTP 500 +page. This vulnerability did not affect production sites as they +typically do not run with "DEBUG = True". +--- + debian/changelog | 3 +++ + django/views/debug.py | 20 +++++++++----------- + tests/view_tests/tests/py3_test_debug.py | 13 +++++++------ + 3 files changed, 19 insertions(+), 17 deletions(-) + +diff --git a/debian/changelog b/debian/changelog +index ace826d..43cc398 100644 +--- a/debian/changelog ++++ b/debian/changelog +@@ -7,6 +7,9 @@ python-django (1:1.10.7-2+deb9u2) stretch-security; urgency=high + accepted any path ending in a slash then a request to a maliciously crafted + URL of that site could lead to a redirect to another site, enabling + phishing and other attacks. (Closes: #905216) ++ * CVE-2017-12794: Fix a cross-site scripting attack in the technical HTTP 500 ++ debug page. This vulnerability did not affect production sites as they ++ typically do not run with "DEBUG = True". (Closes: #874415) + + -- Chris Lamb Thu, 02 Aug 2018 10:37:28 +0800 + +diff --git a/django/views/debug.py b/django/views/debug.py +index 0ed55fd..327ff43 100644 +--- a/django/views/debug.py ++++ b/django/views/debug.py +@@ -775,38 +775,37 @@ TECHNICAL_500_TEMPLATE = (""" +

Traceback {% if not is_email %} + Switch to copy-and-paste view{% endif %} +

+- {% autoescape off %} +
+
    + {% for frame in frames %} + {% ifchanged frame.exc_cause %}{% if frame.exc_cause %} +
  • + {% if frame.exc_cause_explicit %} +- The above exception ({{ frame.exc_cause }}) was the direct cause of the following exception: ++ The above exception ({{ frame.exc_cause|force_escape }}) was the direct cause of the following exception: + {% else %} +- During handling of the above exception ({{ frame.exc_cause }}), another exception occurred: ++ During handling of the above exception ({{ frame.exc_cause|force_escape }}), another exception occurred: + {% endif %} +

  • + {% endif %}{% endifchanged %} +
  • +- {{ frame.filename|escape }} in {{ frame.function|escape }} ++ {{ frame.filename }} in {{ frame.function }} + + {% if frame.context_line %} +
    + {% if frame.pre_context and not is_email %} +
      + {% for line in frame.pre_context %} +-
    1. {{ line|escape }}
    2. ++
    3. {{ line }}
    4. + {% endfor %} +
    + {% endif %} +
      +
    1. +-"""            """{{ frame.context_line|escape }}
      {% if not is_email %} ...{% endif %}
    ++""" """{{ frame.context_line }}{% if not is_email %} ...{% endif %}
  • + {% if frame.post_context and not is_email %} +
      + {% for line in frame.post_context %} +-
    1. {{ line|escape }}
    2. ++
    3. {{ line }}
    4. + {% endfor %} +
    + {% endif %} +@@ -831,7 +830,7 @@ TECHNICAL_500_TEMPLATE = (""" + + {% for var in frame.vars|dictsort:0 %} + +- {{ var.0|force_escape }} ++ {{ var.0 }} +
    {{ var.1 }}
    + + {% endfor %} +@@ -842,7 +841,6 @@ TECHNICAL_500_TEMPLATE = (""" + {% endfor %} +
+
+- {% endautoescape %} +
+ {% if not is_email %} +
+@@ -888,9 +886,9 @@ In template {{ template_info.name }}, error at line {{ template_info.line }} + + Traceback:{% for frame in frames %} + {% ifchanged frame.exc_cause %}{% if frame.exc_cause %}{% if frame.exc_cause_explicit %} +-The above exception ({{ frame.exc_cause }}) was the direct cause of the following exception: ++The above exception ({{ frame.exc_cause|force_escape }}) was the direct cause of the following exception: + {% else %} +-During handling of the above exception ({{ frame.exc_cause }}), another exception occurred: ++During handling of the above exception ({{ frame.exc_cause|force_escape }}), another exception occurred: + {% endif %}{% endif %}{% endifchanged %} + File "{{ frame.filename|escape }}" in {{ frame.function|escape }} + {% if frame.context_line %} {{ frame.lineno }}. {{ frame.context_line|escape }}{% endif %}{% endfor %} +diff --git a/tests/view_tests/tests/py3_test_debug.py b/tests/view_tests/tests/py3_test_debug.py +index 30201ba..316179a 100644 +--- a/tests/view_tests/tests/py3_test_debug.py ++++ b/tests/view_tests/tests/py3_test_debug.py +@@ -9,6 +9,7 @@ error (raise ... from ...) can't be silenced using NOQA. + import sys + + from django.test import RequestFactory, TestCase ++from django.utils.safestring import mark_safe + from django.views.debug import ExceptionReporter + + +@@ -20,10 +21,10 @@ class Py3ExceptionReporterTests(TestCase): + request = self.rf.get('/test_view/') + try: + try: +- raise AttributeError('Top level') ++ raise AttributeError(mark_safe('

Top level

')) + except AttributeError as explicit: + try: +- raise ValueError('Second exception') from explicit ++ raise ValueError('

Second exception

') from explicit + except ValueError: + raise IndexError('Final exception') + except Exception: +@@ -37,9 +38,9 @@ class Py3ExceptionReporterTests(TestCase): + html = reporter.get_traceback_html() + # Both messages are twice on page -- one rendered as html, + # one as plain text (for pastebin) +- self.assertEqual(2, html.count(explicit_exc.format("Top level"))) +- self.assertEqual(2, html.count(implicit_exc.format("Second exception"))) ++ self.assertEqual(2, html.count(explicit_exc.format('<p>Top level</p>'))) ++ self.assertEqual(2, html.count(implicit_exc.format('<p>Second exception</p>'))) + + text = reporter.get_traceback_text() +- self.assertIn(explicit_exc.format("Top level"), text) +- self.assertIn(implicit_exc.format("Second exception"), text) ++ self.assertIn(explicit_exc.format('

Top level

'), text) ++ self.assertIn(implicit_exc.format('

Second exception

'), text) diff --git a/debian/patches/series b/debian/patches/series index 13daeb8c4..0b2048f8f 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -5,3 +5,5 @@ fix-migration-fake-initial-2.patch fix-test-middleware-classes-headers.patch 0013-CVE-2018-7536.patch 0014-CVE-2018-7537.patch +0015-CVE-2018-14574.patch +0016-CVE-2017-12794.patch