Repository: urllib3/urllib3

Issue:
KeyError can be triggered by a server
I noticed a behavior in urllib3 where a server can trigger an unexpected KeyError exception.

This happens if a server causes a redirect to a protocol that is not http or https (can also be a bogus protocol).

To reproduce you need a server that creates a simple redirect, e.g. with a python CGI something like this:
```
#!/usr/bin/python3
print("location: a://b")
print("")
```

Now do a request with urllib3's poolmanager, e.g.:
```
import urllib3
pool = urllib3.PoolManager()
try:
    r = pool.request("GET", 'http://localhost/)
except (ConnectionRefusedError, ConnectionResetError,
        urllib3.exceptions.HTTPError):
    pass
```

A similar issue has already been reported in issue #872. However it was closed without a fix and the discussion there indicates that this was due to the fact that one expects programmers not to provide invalid input.
This scenario here is different as the bug can be triggered by a server, which is not necessarily under control or trusted by the application. Causing a KeyError is a very unexpected thing for an HTTP library, so I can imagine that there are situations where this can be used to crash applications with an unexpected / unhandled exception.


Discussion:

Resolving patch:
diff --git a/src/urllib3/exceptions.py b/src/urllib3/exceptions.py
--- a/src/urllib3/exceptions.py
+++ b/src/urllib3/exceptions.py
@@ -153,6 +153,16 @@ def __init__(self, location):
         self.location = location
 
 
+class URLSchemeUnknown(LocationValueError):
+    "Raised when a URL input has an unsupported scheme."
+
+    def __init__(self, scheme):
+        message = "Not supported URL scheme %s" % scheme
+        super(URLSchemeUnknown, self).__init__(message)
+
+        self.scheme = scheme
+
+
 class ResponseError(HTTPError):
     "Used as a container for an error reason supplied in a MaxRetryError."
     GENERIC_ERROR = "too many error responses"
@@ -236,7 +246,7 @@ class InvalidHeader(HTTPError):
     pass
 
 
-class ProxySchemeUnknown(AssertionError, ValueError):
+class ProxySchemeUnknown(AssertionError, URLSchemeUnknown):
     "ProxyManager does not support the supplied scheme"
     # TODO(t-8ch): Stop inheriting from AssertionError in v2.0.
 
diff --git a/src/urllib3/poolmanager.py b/src/urllib3/poolmanager.py
--- a/src/urllib3/poolmanager.py
+++ b/src/urllib3/poolmanager.py
@@ -14,6 +14,7 @@
     MaxRetryError,
     ProxySchemeUnknown,
     ProxySchemeUnsupported,
+    URLSchemeUnknown,
 )
 from .packages import six
 from .packages.six.moves.urllib.parse import urljoin
@@ -255,7 +256,9 @@ def connection_from_context(self, request_context):
         value must be a key in ``key_fn_by_scheme`` instance variable.
         """
         scheme = request_context["scheme"].lower()
-        pool_key_constructor = self.key_fn_by_scheme[scheme]
+        pool_key_constructor = self.key_fn_by_scheme.get(scheme)
+        if not pool_key_constructor:
+            raise URLSchemeUnknown(scheme)
         pool_key = pool_key_constructor(request_context)
 
         return self.connection_from_pool_key(pool_key, request_context=request_context)


Test patch:
diff --git a/test/with_dummyserver/test_poolmanager.py b/test/with_dummyserver/test_poolmanager.py
--- a/test/with_dummyserver/test_poolmanager.py
+++ b/test/with_dummyserver/test_poolmanager.py
@@ -6,7 +6,7 @@
 from dummyserver.testcase import HTTPDummyServerTestCase, IPv6HTTPDummyServerTestCase
 from urllib3.poolmanager import PoolManager
 from urllib3.connectionpool import port_by_scheme
-from urllib3.exceptions import MaxRetryError
+from urllib3.exceptions import MaxRetryError, URLSchemeUnknown
 from urllib3.util.retry import Retry
 
 from test import LONG_TIMEOUT
@@ -223,6 +223,29 @@ def test_redirect_without_preload_releases_connection(self):
             assert r._pool.num_connections == 1
             assert len(http.pools) == 1
 
+    def test_unknown_scheme(self):
+        with PoolManager() as http:
+            unknown_scheme = "unknown"
+            unknown_scheme_url = "%s://host" % unknown_scheme
+            with pytest.raises(URLSchemeUnknown) as e:
+                r = http.request("GET", unknown_scheme_url)
+            assert e.value.scheme == unknown_scheme
+            r = http.request(
+                "GET",
+                "%s/redirect" % self.base_url,
+                fields={"target": unknown_scheme_url},
+                redirect=False,
+            )
+            assert r.status == 303
+            assert r.headers.get("Location") == unknown_scheme_url
+            with pytest.raises(URLSchemeUnknown) as e:
+                r = http.request(
+                    "GET",
+                    "%s/redirect" % self.base_url,
+                    fields={"target": unknown_scheme_url},
+                )
+            assert e.value.scheme == unknown_scheme
+
     def test_raise_on_redirect(self):
         with PoolManager() as http:
             r = http.request(