Repository: urllib3/urllib3

Issue:
PoolManagers are overly case-sensitive.
In a URI, both scheme and authority are case-insensitive: that is, the scheme, host, and port should all be considered case insensitively (and basically treated as lower-case).

Unfortunately, the PoolManager and ProxyManager do not consistently do that. The biggest problem is in the ProxyManager, where `connection_from_host` will incorrectly treat any HTTPS scheme with a capital letter in it like a HTTP connection (which isn't great), but the flaw is pretty common elsewhere in those objects (e.g. an uppercased host will get a different connection pool to a lowercased host).

At the very minimum we should be using a lowercased scheme at all points in the urllib3 stack. We should also consider whether we want to be case-sensitive about domains (I'm inclined to say that we do not).

Related: kennethreitz/requests#3075.



Discussion:
+1, we should build this behaviour into our new PoolKey namedtuples somehow. Maybe we need a duck interface that returns normalized tuple values given some request context. Like:

``` python
pool_key = pool_key_cls.from_request(request_content)  # Or some better method name
```

Then we can build in the lower-casing normalizing logic into the pool_key_cls as-needed.

I'm not entirely sure that's enough, but to be fair I'm not sat down with the code right now so I can't be sure. 


Resolving patch:
diff --git a/urllib3/util/url.py b/urllib3/util/url.py
--- a/urllib3/util/url.py
+++ b/urllib3/util/url.py
@@ -10,7 +10,8 @@
 class Url(namedtuple('Url', url_attrs)):
     """
     Datastructure for representing an HTTP URL. Used as a return value for
-    :func:`parse_url`.
+    :func:`parse_url`. Both the scheme and host are normalized as they are
+    both case-insensitive according to RFC 3986.
     """
     slots = ()
 
@@ -18,6 +19,10 @@ def __new__(cls, scheme=None, auth=None, host=None, port=None, path=None,
                 query=None, fragment=None):
         if path and not path.startswith('/'):
             path = '/' + path
+        if scheme:
+            scheme = scheme.lower()
+        if host:
+            host = host.lower()
         return super(Url, cls).__new__(cls, scheme, auth, host, port, path,
                                        query, fragment)
 


Test patch:
diff --git a/test/test_util.py b/test/test_util.py
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -77,13 +77,13 @@ def test_get_host(self):
             'http://[2a00:1450:4001:c01::67]:80/test': ('http', '[2a00:1450:4001:c01::67]', 80),
 
             # More IPv6 from http://www.ietf.org/rfc/rfc2732.txt
-            'http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:8000/index.html': ('http', '[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]', 8000),
-            'http://[1080:0:0:0:8:800:200C:417A]/index.html': ('http', '[1080:0:0:0:8:800:200C:417A]', None),
+            'http://[fedc:ba98:7654:3210:fedc:ba98:7654:3210]:8000/index.html': ('http', '[fedc:ba98:7654:3210:fedc:ba98:7654:3210]', 8000),
+            'http://[1080:0:0:0:8:800:200c:417a]/index.html': ('http', '[1080:0:0:0:8:800:200c:417a]', None),
             'http://[3ffe:2a00:100:7031::1]': ('http', '[3ffe:2a00:100:7031::1]', None),
-            'http://[1080::8:800:200C:417A]/foo': ('http', '[1080::8:800:200C:417A]', None),
+            'http://[1080::8:800:200c:417a]/foo': ('http', '[1080::8:800:200c:417a]', None),
             'http://[::192.9.5.5]/ipng': ('http', '[::192.9.5.5]', None),
-            'http://[::FFFF:129.144.52.38]:42/index.html': ('http', '[::FFFF:129.144.52.38]', 42),
-            'http://[2010:836B:4179::836B:4179]': ('http', '[2010:836B:4179::836B:4179]', None),
+            'http://[::ffff:129.144.52.38]:42/index.html': ('http', '[::ffff:129.144.52.38]', 42),
+            'http://[2010:836b:4179::836b:4179]': ('http', '[2010:836b:4179::836b:4179]', None),
         }
         for url, expected_host in url_host_map.items():
             returned_host = get_host(url)
@@ -100,6 +100,35 @@ def test_invalid_host(self):
         for location in invalid_host:
             self.assertRaises(LocationParseError, get_host, location)
 
+    def test_host_normalization(self):
+        """Asserts the scheme and host is normalized to lower-case."""
+        url_host_map = {
+            # Hosts
+            'HTTP://GOOGLE.COM/mail/': ('http', 'google.com', None),
+            'GOogle.COM/mail': ('http', 'google.com', None),
+            'HTTP://GoOgLe.CoM:8000/mail/': ('http', 'google.com', 8000),
+            'HTTP://user:password@EXAMPLE.COM:1234': ('http', 'example.com', 1234),
+            '173.194.35.7': ('http', '173.194.35.7', None),
+            'HTTP://173.194.35.7': ('http', '173.194.35.7', None),
+            'HTTP://[2a00:1450:4001:c01::67]:80/test': ('http', '[2a00:1450:4001:c01::67]', 80),
+            'HTTP://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:8000/index.html': ('http', '[fedc:ba98:7654:3210:fedc:ba98:7654:3210]', 8000),
+            'HTTPS://[1080:0:0:0:8:800:200c:417A]/index.html': ('https', '[1080:0:0:0:8:800:200c:417a]', None),
+        }
+        for url, expected_host in url_host_map.items():
+            returned_host = get_host(url)
+            self.assertEqual(returned_host, expected_host)
+
+    def test_parse_url_normalization(self):
+        """Assert parse_url normalizes the scheme/host, and only the scheme/host"""
+        test_urls = [
+            ('HTTP://GOOGLE.COM/MAIL/', 'http://google.com/MAIL/'),
+            ('HTTP://JeremyCline:Hunter2@Example.com:8080/', 'http://JeremyCline:Hunter2@example.com:8080/'),
+            ('HTTPS://Example.Com/?Key=Value', 'https://example.com/?Key=Value'),
+            ('Https://Example.Com/#Fragment', 'https://example.com/#Fragment'),
+        ]
+        for url, expected_normalized_url in test_urls:
+            actual_normalized_url = parse_url(url).url
+            self.assertEqual(actual_normalized_url, expected_normalized_url)
 
     parse_url_host_map = {
         'http://google.com/mail': Url('http', host='google.com', path='/mail'),
diff --git a/test/with_dummyserver/test_proxy_poolmanager.py b/test/with_dummyserver/test_proxy_poolmanager.py
--- a/test/with_dummyserver/test_proxy_poolmanager.py
+++ b/test/with_dummyserver/test_proxy_poolmanager.py
@@ -300,6 +300,16 @@ def test_https_proxy_pool_timeout(self):
         except MaxRetryError as e:
             self.assertEqual(type(e.reason), ConnectTimeoutError)
 
+    def test_scheme_host_case_insensitive(self):
+        """Assert that upper-case schemes and hosts are normalized."""
+        http = proxy_from_url(self.proxy_url.upper())
+
+        r = http.request('GET', '%s/' % self.http_url.upper())
+        self.assertEqual(r.status, 200)
+
+        r = http.request('GET', '%s/' % self.https_url.upper())
+        self.assertEqual(r.status, 200)
+
 
 class TestIPv6HTTPProxyManager(IPv6HTTPDummyProxyTestCase):