Repository: urllib3/urllib3

Issue:
SecureTransport does not close unverified connections correctly
SecureTransport does not handle SSL custom-verification failure correctly. Instead of sending an alert and terminating the socket, the client socket leaks thus hanging the server socket.

#### Expected behavior
When custom-verification is enabled, `SecureTransport` should terminate the connection.

#### Actual behavior
While `SecureTransport` fails custom validation, an `SSLError` is raised but the socket itself is not terminated:
https://github.com/urllib3/urllib3/blob/d79e82a698d51dc169ad07795812c3936b11bbc8/src/urllib3/contrib/securetransport.py#L446-L449
this leads to a `conn.close()`:
https://github.com/urllib3/urllib3/blob/d79e82a698d51dc169ad07795812c3936b11bbc8/src/urllib3/connectionpool.py#L754-L760
but `conn.sock` is still `None`, it will only be assigned the socket *after* (and only if) the socket is wrapped successfully:
https://github.com/urllib3/urllib3/blob/d79e82a698d51dc169ad07795812c3936b11bbc8/src/urllib3/connection.py#L389

#### Reproduction
This snippet hangs the server eventually instead of failing `NUM_CONNECTIONS` times cleanly.
```python
import logging
import socket
import ssl
import threading

import trustme
import urllib3
from urllib3.contrib.securetransport import inject_into_urllib3, extract_from_urllib3

CERT_PATH = "/tmp/ca.pem"
SSL_CTX2 = ssl.SSLContext()
NUM_CONNECTIONS = 3

def server(s):
    for i in range(NUM_CONNECTIONS):
        c, _ = s.accept()
        try:
            logging.critical("Server iter")
            c = SSL_CTX2.wrap_socket(c, server_side=True)
            c.recv(1024)
            c.sendall(b"HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n")
            c.close()
        except Exception as e:
            logging.critical("Server ex: %r" % (e,))

def connect_and_fail(port):
    with urllib3.HTTPSConnectionPool("localhost", port, cert_reqs="REQUIRED", ca_certs=CERT_PATH) as p:
        for i in range(NUM_CONNECTIONS):
            logging.critical("Client iter")
            try:
                p.request("GET", "/", retries=False)
            except urllib3.exceptions.SSLError as e:
                logging.critical("Client ex: %r" % (e,))

def main():
    logging.basicConfig(level=logging.INFO)
    # client's chain - ca
    ca = trustme.CA()
    ca.cert_pem.write_to_path(CERT_PATH)
    # server's chain - ca2
    ca2 = trustme.CA()
    server2_cert = ca2.issue_cert(u"localhost")
    server2_cert.configure_cert(SSL_CTX2)

    # start an SSL server with ca chain
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(('', 0))
    port = s.getsockname()[1]
    s.listen(10)
    th = threading.Thread(target=server, args=(s, ))
    th.start()
    
    # inject SecureTransport and try to connect with ca2 chain
    inject_into_urllib3()
    connect_and_fail(port)
    extract_from_urllib3()
    th.join()
```


Discussion:

Resolving patch:
diff --git a/src/urllib3/contrib/_securetransport/low_level.py b/src/urllib3/contrib/_securetransport/low_level.py
--- a/src/urllib3/contrib/_securetransport/low_level.py
+++ b/src/urllib3/contrib/_securetransport/low_level.py
@@ -13,6 +13,7 @@
 import os
 import re
 import ssl
+import struct
 import tempfile
 
 from .bindings import CFConst, CoreFoundation, Security
@@ -370,3 +371,26 @@ def _load_client_cert_chain(keychain, *paths):
     finally:
         for obj in itertools.chain(identities, certificates):
             CoreFoundation.CFRelease(obj)
+
+
+TLS_PROTOCOL_VERSIONS = {
+    "SSLv2": (0, 2),
+    "SSLv3": (3, 0),
+    "TLSv1": (3, 1),
+    "TLSv1.1": (3, 2),
+    "TLSv1.2": (3, 3),
+}
+
+
+def _build_tls_unknown_ca_alert(version):
+    """
+    Builds a TLS alert record for an unknown CA.
+    """
+    ver_maj, ver_min = TLS_PROTOCOL_VERSIONS[version]
+    severity_fatal = 0x02
+    description_unknown_ca = 0x30
+    msg = struct.pack(">BB", severity_fatal, description_unknown_ca)
+    msg_len = len(msg)
+    record_type_alert = 0x15
+    record = struct.pack(">BBBH", record_type_alert, ver_maj, ver_min, msg_len) + msg
+    return record
diff --git a/src/urllib3/contrib/securetransport.py b/src/urllib3/contrib/securetransport.py
--- a/src/urllib3/contrib/securetransport.py
+++ b/src/urllib3/contrib/securetransport.py
@@ -60,6 +60,7 @@
 import shutil
 import socket
 import ssl
+import struct
 import threading
 import weakref
 
@@ -69,6 +70,7 @@
 from ._securetransport.bindings import CoreFoundation, Security, SecurityConst
 from ._securetransport.low_level import (
     _assert_no_error,
+    _build_tls_unknown_ca_alert,
     _cert_array_from_pem,
     _create_cfstring_array,
     _load_client_cert_chain,
@@ -397,11 +399,37 @@ def _custom_validate(self, verify, trust_bundle):
         Called when we have set custom validation. We do this in two cases:
         first, when cert validation is entirely disabled; and second, when
         using a custom trust DB.
+        Raises an SSLError if the connection is not trusted.
         """
         # If we disabled cert validation, just say: cool.
         if not verify:
             return
 
+        successes = (
+            SecurityConst.kSecTrustResultUnspecified,
+            SecurityConst.kSecTrustResultProceed,
+        )
+        try:
+            trust_result = self._evaluate_trust(trust_bundle)
+            if trust_result in successes:
+                return
+            reason = "error code: %d" % (trust_result,)
+        except Exception as e:
+            # Do not trust on error
+            reason = "exception: %r" % (e,)
+
+        # SecureTransport does not send an alert nor shuts down the connection.
+        rec = _build_tls_unknown_ca_alert(self.version())
+        self.socket.sendall(rec)
+        # close the connection immediately
+        # l_onoff = 1, activate linger
+        # l_linger = 0, linger for 0 seoncds
+        opts = struct.pack("ii", 1, 0)
+        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, opts)
+        self.close()
+        raise ssl.SSLError("certificate verify failed, %s" % reason)
+
+    def _evaluate_trust(self, trust_bundle):
         # We want data in memory, so load it up.
         if os.path.isfile(trust_bundle):
             with open(trust_bundle, "rb") as f:
@@ -439,15 +467,7 @@ def _custom_validate(self, verify, trust_bundle):
             if cert_array is not None:
                 CoreFoundation.CFRelease(cert_array)
 
-        # Ok, now we can look at what the result was.
-        successes = (
-            SecurityConst.kSecTrustResultUnspecified,
-            SecurityConst.kSecTrustResultProceed,
-        )
-        if trust_result.value not in successes:
-            raise ssl.SSLError(
-                "certificate verify failed, error code: %d" % trust_result.value
-            )
+        return trust_result.value
 
     def handshake(
         self,


Test patch:
diff --git a/test/with_dummyserver/test_https.py b/test/with_dummyserver/test_https.py
--- a/test/with_dummyserver/test_https.py
+++ b/test/with_dummyserver/test_https.py
@@ -477,29 +477,31 @@ def test_assert_fingerprint_sha256(self):
             https_pool.request("GET", "/")
 
     def test_assert_invalid_fingerprint(self):
+        def _test_request(pool):
+            with pytest.raises(MaxRetryError) as cm:
+                pool.request("GET", "/", retries=0)
+            assert isinstance(cm.value.reason, SSLError)
+            return cm.value.reason
+
         with HTTPSConnectionPool(
-            "127.0.0.1", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            self.host, self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
         ) as https_pool:
+
             https_pool.assert_fingerprint = (
                 "AA:AA:AA:AA:AA:AAAA:AA:AAAA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA"
             )
-
-            def _test_request(pool):
-                with pytest.raises(MaxRetryError) as cm:
-                    pool.request("GET", "/", retries=0)
-                assert isinstance(cm.value.reason, SSLError)
-
-            _test_request(https_pool)
-            https_pool._get_conn()
+            e = _test_request(https_pool)
+            assert "Fingerprints did not match." in str(e)
 
             # Uneven length
             https_pool.assert_fingerprint = "AA:A"
-            _test_request(https_pool)
-            https_pool._get_conn()
+            e = _test_request(https_pool)
+            assert "Fingerprint of invalid length:" in str(e)
 
             # Invalid length
             https_pool.assert_fingerprint = "AA"
-            _test_request(https_pool)
+            e = _test_request(https_pool)
+            assert "Fingerprint of invalid length:" in str(e)
 
     def test_verify_none_and_bad_fingerprint(self):
         with HTTPSConnectionPool(
diff --git a/test/with_dummyserver/test_socketlevel.py b/test/with_dummyserver/test_socketlevel.py
--- a/test/with_dummyserver/test_socketlevel.py
+++ b/test/with_dummyserver/test_socketlevel.py
@@ -1427,6 +1427,49 @@ def test_load_verify_locations_exception(self):
         with pytest.raises(SSLError):
             ssl_wrap_socket(None, ca_certs="/tmp/fake-file")
 
+    def test_ssl_custom_validation_failure_terminates(self, tmpdir):
+        """
+        Ensure that the underlying socket is terminated if custom validation fails.
+        """
+        server_closed = Event()
+
+        def is_closed_socket(sock):
+            try:
+                sock.settimeout(SHORT_TIMEOUT)  # Python 3
+                sock.recv(1)  # Python 2
+            except (OSError, socket.error):
+                return True
+            return False
+
+        def socket_handler(listener):
+            sock = listener.accept()[0]
+            try:
+                _ = ssl.wrap_socket(
+                    sock,
+                    server_side=True,
+                    keyfile=DEFAULT_CERTS["keyfile"],
+                    certfile=DEFAULT_CERTS["certfile"],
+                    ca_certs=DEFAULT_CA,
+                )
+            except ssl.SSLError as e:
+                assert "alert unknown ca" in str(e)
+                if is_closed_socket(sock):
+                    server_closed.set()
+
+        self._start_server(socket_handler)
+
+        # client uses a different ca
+        other_ca = trustme.CA()
+        other_ca_path = str(tmpdir / "ca.pem")
+        other_ca.cert_pem.write_to_path(other_ca_path)
+
+        with HTTPSConnectionPool(
+            self.host, self.port, cert_reqs="REQUIRED", ca_certs=other_ca_path
+        ) as pool:
+            with pytest.raises(SSLError):
+                pool.request("GET", "/", retries=False, timeout=LONG_TIMEOUT)
+        assert server_closed.wait(LONG_TIMEOUT), "The socket was not terminated"
+
 
 class TestErrorWrapping(SocketDummyServerTestCase):
     def test_bad_statusline(self):