[Python-modules-commits] [python-django] 01/01: Backport multiple security fixes released in 1.4 branch

Raphaël Hertzog hertzog at moszumanska.debian.org
Thu Jan 29 11:13:56 UTC 2015


This is an automated email from the git hooks/post-receive script.

hertzog pushed a commit to branch debian/squeeze
in repository python-django.

commit bf926d63d31242c57bc113714ac4f1f18821fa3d
Author: Raphaël Hertzog <hertzog at debian.org>
Date:   Wed Jan 28 18:41:29 2015 +0100

    Backport multiple security fixes released in 1.4 branch
    
    * Backport multiple security fixes released in 1.4 branch:
      https://www.djangoproject.com/weblog/2015/jan/13/security/
       - WSGI header spoofing via underscore/dash conflation (CVE-2015-0219)
       - Possible XSS attack via user-supplied redirect URLs (CVE-2015-0220)
       - Denial-of-service attack against django.views.static.serve
         (CVE-2015-0221)
     * Also include a fix for a regression introduced by the patch for
       CVE-2015-0221: https://code.djangoproject.com/ticket/24158
---
 debian/changelog                                 |  13 ++
 debian/patches/CVE-2015-0219.diff                | 113 ++++++++++
 debian/patches/CVE-2015-0220.diff                |  17 ++
 debian/patches/CVE-2015-0221-regression-fix.diff | 255 +++++++++++++++++++++++
 debian/patches/CVE-2015-0221.diff                |  75 +++++++
 debian/patches/series                            |   5 +
 6 files changed, 478 insertions(+)

diff --git a/debian/changelog b/debian/changelog
index dba59dd..7a68301 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,16 @@
+python-django (1.2.3-3+squeeze12) squeeze-lts; urgency=medium
+
+  * Backport multiple security fixes released in 1.4 branch:
+    https://www.djangoproject.com/weblog/2015/jan/13/security/
+     - WSGI header spoofing via underscore/dash conflation (CVE-2015-0219)
+     - Possible XSS attack via user-supplied redirect URLs (CVE-2015-0220)
+     - Denial-of-service attack against django.views.static.serve
+       (CVE-2015-0221)
+   * Also include a fix for a regression introduced by the patch for
+     CVE-2015-0221: https://code.djangoproject.com/ticket/24158
+
+ -- Raphaël Hertzog <hertzog at debian.org>  Wed, 28 Jan 2015 18:39:56 +0100
+
 python-django (1.2.3-3+squeeze11) squeeze-lts; urgency=low
 
   * LTS security upload:
diff --git a/debian/patches/CVE-2015-0219.diff b/debian/patches/CVE-2015-0219.diff
new file mode 100644
index 0000000..b40a4fe
--- /dev/null
+++ b/debian/patches/CVE-2015-0219.diff
@@ -0,0 +1,113 @@
+Author: Carl Meyer <carl at oddbird.net>
+Date:   Wed Sep 10 11:06:19 2014 -0600
+Subject: Stripped headers containing underscores to prevent spoofing in WSGI environ.
+    
+    This is a security fix. Disclosure following shortly.
+    
+    Thanks to Jedediah Smith for the report.
+
+Patch further backported to Django 1.2 by Raphaël Hertzog.
+
+Origin: backport, https://github.com/django/django/commit/d7597b31d5c03106eeba4be14a33b32a5e25f4ee
+Bug: https://code.djangoproject.com/ticket/24239
+
+--- a/django/core/servers/basehttp.py
++++ b/django/core/servers/basehttp.py
+@@ -583,6 +583,14 @@ class WSGIRequestHandler(BaseHTTPRequest
+         if length:
+             env['CONTENT_LENGTH'] = length
+ 
++        # Strip all headers with underscores in the name before constructing
++        # the WSGI environ. This prevents header-spoofing based on ambiguity
++        # between underscores and dashes both normalized to underscores in WSGI
++        # env vars. Nginx and Apache 2.4+ both do this as well.
++        for k, v in self.headers.items():
++            if '_' in k:
++                del self.headers[k]
++
+         for h in self.headers.headers:
+             k,v = h.split(':',1)
+             k=k.replace('-','_').upper(); v=v.strip()
+--- a/tests/regressiontests/servers/tests.py
++++ b/tests/regressiontests/servers/tests.py
+@@ -3,11 +3,13 @@ Tests for django.core.servers.
+ """
+ 
+ import os
++import sys
++from StringIO import StringIO
+ 
+ import django
+ from django.test import TestCase
+ from django.core.handlers.wsgi import WSGIHandler
+-from django.core.servers.basehttp import AdminMediaHandler
++from django.core.servers.basehttp import AdminMediaHandler, WSGIRequestHandler
+ 
+ 
+ class AdminMediaHandlerTests(TestCase):
+@@ -65,3 +67,65 @@ class AdminMediaHandlerTests(TestCase):
+                 continue
+             self.fail('URL: %s should have caused a ValueError exception.'
+                       % url)
++
++
++class Stub(object):
++    def __init__(self, **kwargs):
++        self.__dict__.update(kwargs)
++
++
++class WSGIRequestHandlerTestCase(TestCase):
++
++    def test_strips_underscore_headers(self):
++        """WSGIRequestHandler ignores headers containing underscores.
++
++        This follows the lead of nginx and Apache 2.4, and is to avoid
++        ambiguity between dashes and underscores in mapping to WSGI environ,
++        which can have security implications.
++        """
++        def test_app(environ, start_response):
++            """A WSGI app that just reflects its HTTP environ."""
++            start_response('200 OK', [])
++            http_environ_items = sorted(
++                '%s:%s' % (k, v) for k, v in environ.items()
++                if k.startswith('HTTP_')
++            )
++            yield (','.join(http_environ_items)).encode('utf-8')
++
++        rfile = StringIO()
++        rfile.write(b"GET / HTTP/1.0\r\n")
++        rfile.write(b"Some-Header: good\r\n")
++        rfile.write(b"Some_Header: bad\r\n")
++        rfile.write(b"Other_Header: bad\r\n")
++        rfile.seek(0)
++
++        # WSGIRequestHandler closes the output file; we need to make this a
++        # no-op so we can still read its contents.
++        class UnclosableStringIO(StringIO):
++            def close(self):
++                pass
++
++        wfile = UnclosableStringIO()
++
++        def makefile(mode, *a, **kw):
++            if mode == 'rb':
++                return rfile
++            elif mode == 'wb':
++                return wfile
++
++        request = Stub(makefile=makefile)
++        server = Stub(base_environ={}, get_app=lambda: test_app)
++
++        # We don't need to check stderr, but we don't want it in test output
++        old_stderr = sys.stderr
++        sys.stderr = StringIO()
++        try:
++            # instantiating a handler runs the request as side effect
++            WSGIRequestHandler(request, '192.168.0.2', server)
++        finally:
++            sys.stderr = old_stderr
++
++        wfile.seek(0)
++        body = list(wfile.readlines())[-1]
++
++        self.assertEqual(body, b'HTTP_SOME_HEADER:good')
diff --git a/debian/patches/CVE-2015-0220.diff b/debian/patches/CVE-2015-0220.diff
new file mode 100644
index 0000000..11ed5cc
--- /dev/null
+++ b/debian/patches/CVE-2015-0220.diff
@@ -0,0 +1,17 @@
+Author: Tim Graham <timograham at gmail.com>
+Date:   Wed Dec 3 16:14:00 2014 -0500
+Subject: Fixed is_safe_url() to handle leading whitespace.
+    
+    This is a security fix. Disclosure following shortly.
+
+Origin: backport, https://github.com/django/django/commit/4c241f1b710da6419d9dca160e80b23b82db7758
+--- a/django/utils/http.py
++++ b/django/utils/http.py
+@@ -132,6 +132,7 @@ def is_safe_url(url, host=None):
+     """
+     if not url:
+         return False
++    url = url.strip()
+     # Chrome treats \ completely as /
+     url = url.replace('\\', '/')
+     # Chrome considers any URL with more than two slashes to be absolute, but
diff --git a/debian/patches/CVE-2015-0221-regression-fix.diff b/debian/patches/CVE-2015-0221-regression-fix.diff
new file mode 100644
index 0000000..55c7d8c
--- /dev/null
+++ b/debian/patches/CVE-2015-0221-regression-fix.diff
@@ -0,0 +1,255 @@
+Author: Benjamin Richter <richter.benjamin at gmail.com>
+Date:   Sun Jan 25 23:22:46 2015 +0100
+Subject: [1.4.x] Fixed #24158 -- Allowed GZipMiddleware to work with streaming responses
+    
+    Backport of django.utils.text.compress_sequence and fix for
+    django.middleware.gzip.GZipMiddleware when using iterators as
+    response.content.
+
+Patch further backported to Django 1.2 by Raphaël Hertzog
+<hertzog at debian.org>.
+
+Origin: backport, https://github.com/django/django/commit/1e39d0f6280abf34c7719db5e7ed1c333f5e5919
+Bug: https://code.djangoproject.com/ticket/24158
+
+--- a/django/middleware/gzip.py
++++ b/django/middleware/gzip.py
+@@ -1,6 +1,6 @@
+ import re
+ 
+-from django.utils.text import compress_string
++from django.utils.text import compress_string, compress_sequence
+ from django.utils.cache import patch_vary_headers
+ 
+ re_accepts_gzip = re.compile(r'\bgzip\b')
+@@ -12,8 +12,10 @@ class GZipMiddleware(object):
+     on the Accept-Encoding header.
+     """
+     def process_response(self, request, response):
++        # The response object can tell us whether content is a string or an iterable
+         # It's not worth compressing non-OK or really short responses.
+-        if response.status_code != 200 or len(response.content) < 200:
++        if response.status_code != 200 or (response._is_string and
++                                           len(response.content) < 200):
+             return response
+ 
+         patch_vary_headers(response, ('Accept-Encoding',))
+@@ -32,7 +34,17 @@ class GZipMiddleware(object):
+         if not re_accepts_gzip.search(ae):
+             return response
+ 
+-        response.content = compress_string(response.content)
++        if response._is_string:
++            compressed_content = compress_string(response.content)
++            response.content = compressed_content
++            response['Content-Length'] = str(len(response.content))
++        else:
++            # If the response content is iterable we don't know the length,
++            # so delete the header.
++            del response['Content-Length']
++            # Wrap the response content in a streaming gzip iterator (direct
++            # access to inner response._container)
++            response.content = compress_sequence(response._container)
++
+         response['Content-Encoding'] = 'gzip'
+-        response['Content-Length'] = str(len(response.content))
+         return response
+--- a/django/utils/text.py
++++ b/django/utils/text.py
+@@ -186,6 +186,40 @@ def compress_string(s):
+ 
+ ustring_re = re.compile(u"([\u0080-\uffff])")
+ 
++# Backported from django 1.5
++class StreamingBuffer(object):
++    def __init__(self):
++        self.vals = []
++
++    def write(self, val):
++        self.vals.append(val)
++
++    def read(self):
++        ret = ''.join(self.vals)
++        self.vals = []
++        return ret
++
++    def flush(self):
++        return
++
++    def close(self):
++        return
++
++# Backported from django 1.5
++# Like compress_string, but for iterators of strings.
++def compress_sequence(sequence):
++    import gzip
++    buf = StreamingBuffer()
++    zfile = gzip.GzipFile(mode='wb', compresslevel=6, fileobj=buf)
++    # Output headers...
++    yield buf.read()
++    for item in sequence:
++        zfile.write(item)
++        zfile.flush()
++        yield buf.read()
++    zfile.close()
++    yield buf.read()
++
+ def javascript_quote(s, quote_double_quotes=False):
+ 
+     def fix(match):
+--- a/django/http/__init__.py
++++ b/django/http/__init__.py
+@@ -326,12 +326,7 @@ class HttpResponse(object):
+         if not content_type:
+             content_type = "%s; charset=%s" % (settings.DEFAULT_CONTENT_TYPE,
+                     self._charset)
+-        if not isinstance(content, basestring) and hasattr(content, '__iter__'):
+-            self._container = content
+-            self._is_string = False
+-        else:
+-            self._container = [content]
+-            self._is_string = True
++        self.content = content
+         self.cookies = CompatCookie()
+         if status:
+             self.status_code = status
+@@ -408,8 +403,12 @@ class HttpResponse(object):
+         return smart_str(''.join(self._container), self._charset)
+ 
+     def _set_content(self, value):
+-        self._container = [value]
+-        self._is_string = True
++        if not isinstance(value, basestring) and hasattr(value, '__iter__'):
++            self._container = value
++            self._is_string = False
++        else:
++            self._container = [value]
++            self._is_string = True
+ 
+     content = property(_get_content, _set_content)
+ 
+--- a/tests/regressiontests/middleware/tests.py
++++ b/tests/regressiontests/middleware/tests.py
+@@ -1,8 +1,13 @@
+ # -*- coding: utf-8 -*-
+ 
++import gzip
++import random
++import StringIO
++
+ from django.test import TestCase
+-from django.http import HttpRequest
++from django.http import HttpRequest, HttpResponse
+ from django.middleware.common import CommonMiddleware
++from django.middleware.gzip import GZipMiddleware
+ from django.conf import settings
+ 
+ class CommonMiddlewareTest(TestCase):
+@@ -125,7 +130,7 @@ class CommonMiddlewareTest(TestCase):
+         self.assertEquals(r.status_code, 301)
+         self.assertEquals(r['Location'],
+                           'http://www.testserver/middleware/slash/')
+-   
++
+ 
+     # The following tests examine expected behavior given a custom urlconf that
+     # overrides the default one through the request object.
+@@ -165,7 +170,7 @@ class CommonMiddlewareTest(TestCase):
+       request = self._get_request('customurlconf/slash')
+       request.urlconf = 'regressiontests.middleware.extra_urls'
+       r = CommonMiddleware().process_request(request)
+-      self.failIf(r is None, 
++      self.failIf(r is None,
+           "CommonMiddlware failed to return APPEND_SLASH redirect using request.urlconf")
+       self.assertEquals(r.status_code, 301)
+       self.assertEquals(r['Location'], 'http://testserver/middleware/customurlconf/slash/')
+@@ -209,7 +214,7 @@ class CommonMiddlewareTest(TestCase):
+       request = self._get_request('customurlconf/needsquoting#')
+       request.urlconf = 'regressiontests.middleware.extra_urls'
+       r = CommonMiddleware().process_request(request)
+-      self.failIf(r is None, 
++      self.failIf(r is None,
+           "CommonMiddlware failed to return APPEND_SLASH redirect using request.urlconf")
+       self.assertEquals(r.status_code, 301)
+       self.assertEquals(
+@@ -246,3 +251,80 @@ class CommonMiddlewareTest(TestCase):
+       self.assertEquals(r.status_code, 301)
+       self.assertEquals(r['Location'],
+                         'http://www.testserver/middleware/customurlconf/slash/')
++
++
++class GZipMiddlewareTest(TestCase):
++    """
++    Tests the GZip middleware.
++    """
++    short_string = "This string is too short to be worth compressing."
++    compressible_string = 'a' * 500
++    uncompressible_string = ''.join(chr(random.randint(0, 255)) for _ in xrange(500))
++    iterator_as_content = iter(compressible_string)
++
++    def setUp(self):
++        self.req = HttpRequest()
++        self.req.META = {
++            'SERVER_NAME': 'testserver',
++            'SERVER_PORT': 80,
++        }
++        self.req.path = self.req.path_info = "/"
++        self.req.META['HTTP_ACCEPT_ENCODING'] = 'gzip, deflate'
++        self.req.META['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 5.1; rv:9.0.1) Gecko/20100101 Firefox/9.0.1'
++        self.resp = HttpResponse()
++        self.resp.status_code = 200
++        self.resp.content = self.compressible_string
++        self.resp['Content-Type'] = 'text/html; charset=UTF-8'
++
++    @staticmethod
++    def decompress(gzipped_string):
++        return gzip.GzipFile(mode='rb', fileobj=StringIO.StringIO(gzipped_string)).read()
++
++    def test_compress_response(self):
++        """
++        Tests that compression is performed on responses with compressible content.
++        """
++        r = GZipMiddleware().process_response(self.req, self.resp)
++        self.assertEqual(self.decompress(r.content), self.compressible_string)
++        self.assertEqual(r['Content-Encoding'], 'gzip')
++        self.assertEqual(r['Content-Length'], str(len(r.content)))
++
++    def test_no_compress_short_response(self):
++        """
++        Tests that compression isn't performed on responses with short content.
++        """
++        self.resp.content = self.short_string
++        r = GZipMiddleware().process_response(self.req, self.resp)
++        self.assertEqual(r.content, self.short_string)
++        self.assertFalse(r.has_header('Content-Encoding'))
++
++    def test_no_compress_compressed_response(self):
++        """
++        Tests that compression isn't performed on responses that are already compressed.
++        """
++        self.resp['Content-Encoding'] = 'deflate'
++        r = GZipMiddleware().process_response(self.req, self.resp)
++        self.assertEqual(r.content, self.compressible_string)
++        self.assertEqual(r['Content-Encoding'], 'deflate')
++
++    def test_no_compress_ie_js_requests(self):
++        """
++        Tests that compression isn't performed on JavaScript requests from Internet Explorer.
++        """
++        self.req.META['HTTP_USER_AGENT'] = 'Mozilla/4.0 (compatible; MSIE 5.00; Windows 98)'
++        self.resp['Content-Type'] = 'application/javascript; charset=UTF-8'
++        r = GZipMiddleware().process_response(self.req, self.resp)
++        self.assertEqual(r.content, self.compressible_string)
++        self.assertFalse(r.has_header('Content-Encoding'))
++
++    def test_streaming_compression(self):
++        """
++        Tests that iterators as response content return a compressed stream without consuming
++        the whole response.content while doing so.
++        See #24158.
++        """
++        self.resp.content = self.iterator_as_content
++        r = GZipMiddleware().process_response(self.req, self.resp)
++        self.assertEqual(self.decompress(''.join(r.content)), self.compressible_string)
++        self.assertEqual(r['Content-Encoding'], 'gzip')
++        self.assertFalse(r.has_header('Content-Length'))
diff --git a/debian/patches/CVE-2015-0221.diff b/debian/patches/CVE-2015-0221.diff
new file mode 100644
index 0000000..8b9c84e
--- /dev/null
+++ b/debian/patches/CVE-2015-0221.diff
@@ -0,0 +1,75 @@
+Author: Tim Graham <timograham at gmail.com>
+Date:   Tue Dec 9 15:32:03 2014 -0500
+Subject: Prevented views.static.serve() from using large memory on large files.
+    
+    This is a security fix. Disclosure following shortly.
+
+Patch further backported to Django 1.2 by Raphaël Hertzog.
+
+Origin: backport, https://github.com/django/django/commit/d020da6646c5142bc092247d218a3d1ce3e993f7
+
+--- a/django/views/static.py
++++ b/django/views/static.py
+@@ -16,6 +16,9 @@ from django.http import Http404, HttpRes
+ from django.template import Template, Context, TemplateDoesNotExist
+ from django.utils.http import http_date
+ 
++STREAM_CHUNK_SIZE = 4096
++
++
+ def serve(request, path, document_root=None, show_indexes=False):
+     """
+     Serve static files below a given point in the directory structure.
+@@ -60,10 +63,11 @@ def serve(request, path, document_root=N
+     if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'),
+                               statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]):
+         return HttpResponseNotModified(mimetype=mimetype)
+-    contents = open(fullpath, 'rb').read()
+-    response = HttpResponse(contents, mimetype=mimetype)
++    f = open(fullpath, 'rb')
++    response = HttpResponse(iter(lambda: f.read(STREAM_CHUNK_SIZE), ''), mimetype=mimetype)
+     response["Last-Modified"] = http_date(statobj[stat.ST_MTIME])
+-    response["Content-Length"] = len(contents)
++    if stat.S_ISREG(statobj.st_mode):
++        response["Content-Length"] = statobj.st_size
+     return response
+ 
+ DEFAULT_DIRECTORY_INDEX_TEMPLATE = """
+--- /dev/null
++++ b/tests/regressiontests/views/media/long-line.txt
+@@ -0,0 +1 @@
++lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua hic tempora est veritatis culpa fugiat doloribus fugit in sed harum veniam porro eveniet maxime labore assumenda non illum possimus aut vero laudantium cum magni numquam dolorem explicabo quidem quasi nesciunt ipsum deleniti facilis neque similique nisi ad magnam accusamus quae provident dolor ab atque modi laboriosam fuga suscipit ea beatae ipsam consequatur saepe d [...]
+--- a/tests/regressiontests/views/tests/static.py
++++ b/tests/regressiontests/views/tests/static.py
+@@ -2,6 +2,8 @@ from os import path
+ 
+ from django.test import TestCase
+ from regressiontests.views.urls import media_dir
++from django.views.static import STREAM_CHUNK_SIZE
++
+ 
+ class StaticTests(TestCase):
+     """Tests django views in django/views/static.py"""
+@@ -12,8 +14,9 @@ class StaticTests(TestCase):
+         for filename in media_files:
+             response = self.client.get('/views/site_media/%s' % filename)
+             file = open(path.join(media_dir, filename))
+-            self.assertEquals(file.read(), response.content)
+-            self.assertEquals(len(response.content), int(response['Content-Length']))
++            content = response.content
++            self.assertEquals(file.read(), content)
++            self.assertEquals(len(content), int(response['Content-Length']))
+ 
+     def test_unknown_mime_type(self):
+         response = self.client.get('/views/site_media/file.unknown')
+@@ -25,3 +28,10 @@ class StaticTests(TestCase):
+         file = open(path.join(media_dir, file_name))
+         self.assertEquals(file.read(), response.content)
+ 
++    def test_chunked(self):
++        "The static view should stream files in chunks to avoid large memory usage"
++        response = self.client.get('/views/site_media/long-line.txt')
++        first_chunk = iter(response).next()
++        self.assertEqual(len(first_chunk), STREAM_CHUNK_SIZE)
++        second_chunk = response.next()
++        self.assertEqual(len(second_chunk), 1451)
diff --git a/debian/patches/series b/debian/patches/series
index b263721..1495daf 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -37,3 +37,8 @@ CVE-2014-0483.patch
 CVE-2014-0480.patch
 get-random-string-backport.patch
 CVE-2014-0481.patch
+CVE-2015-0219.diff
+CVE-2015-0220.diff
+CVE-2015-0221.diff
+CVE-2015-0221-regression-fix.diff
+

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/python-django.git



More information about the Python-modules-commits mailing list