Repository: benoitc/gunicorn

Issue:
Gunicorn fails with a 500, instead of a 400, when a request path is a malformed IPv6 address
Request:

```
~/.v/tempenv-701523535780 $ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
PUT ///]ufd/: HTTP/1.1
HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: text/html
Content-Length: 141

<html>
  <head>
    <title>Internal Server Error</title>
  </head>
  <body>
    <h1><p>Internal Server Error</p></h1>

  </body>
</html>
Connection closed by foreign host.
```

Server:

``` pytb
(tempenv-027621611529f) ~/.v/tempenv-701523535780 $ gunicorn w:f
[2015-05-09 11:53:50 -0400] [75550] [INFO] Starting gunicorn 19.3.0
[2015-05-09 11:53:50 -0400] [75550] [INFO] Listening at: http://127.0.0.1:8000 (75550)
[2015-05-09 11:53:50 -0400] [75550] [INFO] Using worker: sync
[2015-05-09 11:53:50 -0400] [75567] [INFO] Booting worker with pid: 75567
[2015-05-09 11:54:44 -0400] [75567] [ERROR] Error handling request
Traceback (most recent call last):
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/workers/sync.py", line 129, in handle
    req = six.next(parser)
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/http/parser.py", line 41, in __next__
    self.mesg = self.mesg_class(self.cfg, self.unreader, self.req_count)
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/http/message.py", line 153, in __init__
    super(Request, self).__init__(cfg, unreader)
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/http/message.py", line 53, in __init__
    unused = self.parse(self.unreader)
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/http/message.py", line 177, in parse
    self.parse_request_line(bytes_to_str(line))
  File "/Users/alex_gaynor/.virtualenvs/tempenv-027621611529f/lib/python2.7/site-packages/gunicorn/http/message.py", line 326, in parse_request_line
    parts = urlsplit(self.uri)
  File "/Users/alex_gaynor/.pyenv/versions/2.7.9/lib/python2.7/urlparse.py", line 214, in urlsplit
    raise ValueError("Invalid IPv6 URL")
ValueError: Invalid IPv6 URL
```

This should result in a 400 bad request, not a 500.

(This was found using the AFL fuzzer)



Discussion:

Resolving patch:
diff --git a/gunicorn/_compat.py b/gunicorn/_compat.py
--- a/gunicorn/_compat.py
+++ b/gunicorn/_compat.py
@@ -2,6 +2,7 @@
 
 from gunicorn import six
 
+PY26 = (sys.version_info[:2] == (2, 6))
 PY33 = (sys.version_info >= (3, 3))
 
 
@@ -200,3 +201,67 @@ def wrap_error(func, *args, **kw):
             if exc.args:
                 _wrap_error(exc, _MAP_ERRNO, exc.args[0])
             raise
+
+if PY26:
+    from urlparse import (
+        _parse_cache, MAX_CACHE_SIZE, clear_cache, _splitnetloc, SplitResult,
+        scheme_chars,
+    )
+
+    def urlsplit(url, scheme='', allow_fragments=True):
+        """Parse a URL into 5 components:
+        <scheme>://<netloc>/<path>?<query>#<fragment>
+        Return a 5-tuple: (scheme, netloc, path, query, fragment).
+        Note that we don't break the components up in smaller bits
+        (e.g. netloc is a single string) and we don't expand % escapes."""
+        allow_fragments = bool(allow_fragments)
+        key = url, scheme, allow_fragments, type(url), type(scheme)
+        cached = _parse_cache.get(key, None)
+        if cached:
+            return cached
+        if len(_parse_cache) >= MAX_CACHE_SIZE: # avoid runaway growth
+            clear_cache()
+        netloc = query = fragment = ''
+        i = url.find(':')
+        if i > 0:
+            if url[:i] == 'http': # optimize the common case
+                scheme = url[:i].lower()
+                url = url[i+1:]
+                if url[:2] == '//':
+                    netloc, url = _splitnetloc(url, 2)
+                    if (('[' in netloc and ']' not in netloc) or
+                            (']' in netloc and '[' not in netloc)):
+                        raise ValueError("Invalid IPv6 URL")
+                if allow_fragments and '#' in url:
+                    url, fragment = url.split('#', 1)
+                if '?' in url:
+                    url, query = url.split('?', 1)
+                v = SplitResult(scheme, netloc, url, query, fragment)
+                _parse_cache[key] = v
+                return v
+            for c in url[:i]:
+                if c not in scheme_chars:
+                    break
+            else:
+                # make sure "url" is not actually a port number (in which case
+                # "scheme" is really part of the path)
+                rest = url[i+1:]
+                if not rest or any(c not in '0123456789' for c in rest):
+                    # not a port number
+                    scheme, url = url[:i].lower(), rest
+
+        if url[:2] == '//':
+            netloc, url = _splitnetloc(url, 2)
+            if (('[' in netloc and ']' not in netloc) or
+                    (']' in netloc and '[' not in netloc)):
+                raise ValueError("Invalid IPv6 URL")
+        if allow_fragments and '#' in url:
+            url, fragment = url.split('#', 1)
+        if '?' in url:
+            url, query = url.split('?', 1)
+        v = SplitResult(scheme, netloc, url, query, fragment)
+        _parse_cache[key] = v
+        return v
+
+else:
+    from gunicorn.six.moves.urllib.parse import urlsplit
diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py
--- a/gunicorn/http/message.py
+++ b/gunicorn/http/message.py
@@ -15,7 +15,7 @@
     LimitRequestLine, LimitRequestHeaders)
 from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest
 from gunicorn.six import BytesIO
-from gunicorn.six.moves.urllib.parse import urlsplit
+from gunicorn._compat import urlsplit
 
 MAX_REQUEST_LINE = 8190
 MAX_HEADERS = 32768
@@ -323,7 +323,10 @@ def parse_request_line(self, line):
         else:
             self.uri = bits[1]
 
-        parts = urlsplit(self.uri)
+        try:
+            parts = urlsplit(self.uri)
+        except ValueError:
+            raise InvalidRequestLine(line)
         self.path = parts.path or ""
         self.query = parts.query or ""
         self.fragment = parts.fragment or ""


Test patch:
diff --git a/tests/requests/invalid/016.http b/tests/requests/invalid/016.http
new file mode 100644
--- /dev/null
+++ b/tests/requests/invalid/016.http
@@ -0,0 +1,2 @@
+PUT ///]ufd/: HTTP/1.1\r\n
+\r\n
\ No newline at end of file
diff --git a/tests/requests/invalid/016.py b/tests/requests/invalid/016.py
new file mode 100644
--- /dev/null
+++ b/tests/requests/invalid/016.py
@@ -0,0 +1,3 @@
+from gunicorn.http.errors import InvalidRequestLine
+
+request = InvalidRequestLine
diff --git a/tests/test_002-invalid-requests.py b/tests/test_002-invalid-requests.py
--- a/tests/test_002-invalid-requests.py
+++ b/tests/test_002-invalid-requests.py
@@ -12,17 +12,15 @@
 
 dirname = os.path.dirname(__file__)
 reqdir = os.path.join(dirname, "requests", "invalid")
+httpfiles = glob.glob(os.path.join(reqdir, "*.http"))
 
+@pytest.mark.parametrize("fname", httpfiles)
+def test_http_parser(fname):
+    env = treq.load_py(os.path.splitext(fname)[0] + ".py")
 
-def test_http_parser():
-    for fname in glob.glob(os.path.join(reqdir, "*.http")):
-        env = treq.load_py(os.path.splitext(fname)[0] + ".py")
+    expect = env["request"]
+    cfg = env["cfg"]
+    req = treq.badrequest(fname)
 
-        expect = env['request']
-        cfg = env['cfg']
-        req = treq.badrequest(fname)
-
-        with pytest.raises(expect):
-            def f(fname):
-                return req.check(cfg)
-            f(fname)
+    with pytest.raises(expect):
+        req.check(cfg)