Repository: urllib3/urllib3

Issue:
Enable hostname_checks_common_name on Python versions that support it
We'll have to wait on the resolution of https://bugs.python.org/issue43522, and then based on that decide how we detect the feature:

 * use an hypothetical new flag?
 * 3.10?
 * 3.7.x, 3.8.x, 3.9.x and 3.10 since this fix will be backported?

We'll have to wait for https://github.com/python/cpython/pull/24899 to land and to be in the 3.10 we use in GitHub Actions, probably when 3.10.0a7 get released.


Discussion:
We'll use version detection, https://docs.python.org/3.10/library/ssl.html#ssl.SSLContext.hostname_checks_common_name documents the version where this works.

Not sure for PyPy yet. It includes the standard library but I don't know if they backport minor versions. I don't even know if the fix is in C or Python.
PyPy does not support this at all: https://foss.heptapod.net/pypy/pypy/-/issues/3539. So we'll continue to use our own matching there.
The problem with reporting bugs to PyPy is that they get fixed quickly :D. Thanks @mattip for this! The good news is that on PyPy checking for `ssl.HAS_NEVER_CHECK_COMMON_NAME` is going to be enough, we don't have to worry about https://bugs.python.org/issue43522.
(PyPy already had much of the fix for the issue in re-using CPython's stdlib v3.8.10, PyPy only needed to additionally expose the `_ssl.HAS_NEVER_CHECK_COMMON_NAME` attribute which I missed when updating the code for Python3.8)

Resolving patch:
diff --git a/src/urllib3/connection.py b/src/urllib3/connection.py
--- a/src/urllib3/connection.py
+++ b/src/urllib3/connection.py
@@ -483,10 +483,7 @@ def connect(self) -> None:
                 # We still support OpenSSL 1.0.2, which prevents us from verifying
                 # hostnames easily: https://github.com/pyca/pyopenssl/pull/933
                 or ssl_.IS_PYOPENSSL
-                # context.hostname_checks_common_name seems ignored, and it's more
-                # important to reject certs without SANs than to rely on the standard
-                # library. See https://bugs.python.org/issue43522 for details.
-                or True
+                or not ssl_.HAS_NEVER_CHECK_COMMON_NAME
             ):
                 self.ssl_context.check_hostname = False
 
diff --git a/src/urllib3/util/ssl_.py b/src/urllib3/util/ssl_.py
--- a/src/urllib3/util/ssl_.py
+++ b/src/urllib3/util/ssl_.py
@@ -19,6 +19,8 @@
 ALPN_PROTOCOLS = ["http/1.1"]
 USE_DEFAULT_SSLCONTEXT_CIPHERS = False
 
+_TYPE_VERSION_INFO = Tuple[int, int, int, str, int]
+
 # Maps the length of a digest to a possible hash function producing this digest
 HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256}
 
@@ -36,6 +38,55 @@ def _is_ge_openssl_v1_1_1(
     )
 
 
+def _is_openssl_issue_14579_fixed(
+    openssl_version_text: str, openssl_version_number: int
+) -> bool:
+    """
+    Returns True for OpenSSL 1.1.1l+ (>=0x101010cf) where this issue was fixed.
+    Before the fix, the SSL_new() API was not copying hostflags like
+    X509_CHECK_FLAG_NEVER_CHECK_SUBJECT, which tripped up CPython.
+    https://github.com/openssl/openssl/issues/14579
+
+    LibreSSL reports a version number of 0x20000000 for
+    OpenSSL version number so we need to filter out LibreSSL.
+    """
+    return (
+        not openssl_version_text.startswith("LibreSSL")
+        and openssl_version_number >= 0x101010CF
+    )
+
+
+def _is_bpo_43522_fixed(
+    implementation_name: str, version_info: _TYPE_VERSION_INFO
+) -> bool:
+    """Return True if PyPy or CPython 3.8.9+, 3.9.3+ or 3.10+ where setting
+    SSLContext.hostname_checks_common_name to False works.
+    https://github.com/urllib3/urllib3/issues/2192#issuecomment-821832963
+    https://foss.heptapod.net/pypy/pypy/-/issues/3539#
+    """
+    if implementation_name != "cpython":
+        return True
+
+    major_minor = version_info[:2]
+    micro = version_info[2]
+    return (
+        (major_minor == (3, 8) and micro >= 9)
+        or (major_minor == (3, 9) and micro >= 3)
+        or major_minor >= (3, 10)
+    )
+
+
+def _is_has_never_check_common_name_reliable(
+    openssl_version: str,
+    openssl_version_number: int,
+    implementation_name: str,
+    version_info: _TYPE_VERSION_INFO,
+) -> bool:
+    return _is_openssl_issue_14579_fixed(
+        openssl_version, openssl_version_number
+    ) or _is_bpo_43522_fixed(implementation_name, version_info)
+
+
 if TYPE_CHECKING:
     from typing_extensions import Literal
 
@@ -67,6 +118,16 @@ def _is_ge_openssl_v1_1_1(
     )
     PROTOCOL_SSLv23 = PROTOCOL_TLS
 
+    # Setting SSLContext.hostname_checks_common_name = False didn't work before CPython
+    # 3.8.9, 3.9.3, and 3.10 (but OK on PyPy) or OpenSSL 1.1.1l+
+    if HAS_NEVER_CHECK_COMMON_NAME and not _is_has_never_check_common_name_reliable(
+        OPENSSL_VERSION,
+        OPENSSL_VERSION_NUMBER,
+        sys.implementation.name,
+        sys.version_info,
+    ):
+        HAS_NEVER_CHECK_COMMON_NAME = False
+
     # Need to be careful here in case old TLS versions get
     # removed in future 'ssl' module implementations.
     for attr in ("TLSv1", "TLSv1_1", "TLSv1_2"):


Test patch:
diff --git a/test/test_util.py b/test/test_util.py
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -24,7 +24,13 @@
 from urllib3.util.proxy import connection_requires_http_tunnel, create_proxy_ssl_context
 from urllib3.util.request import _FAILEDTELL, make_headers, rewind_body
 from urllib3.util.response import assert_header_parsing
-from urllib3.util.ssl_ import resolve_cert_reqs, resolve_ssl_version, ssl_wrap_socket
+from urllib3.util.ssl_ import (
+    _TYPE_VERSION_INFO,
+    _is_has_never_check_common_name_reliable,
+    resolve_cert_reqs,
+    resolve_ssl_version,
+    ssl_wrap_socket,
+)
 from urllib3.util.timeout import Timeout
 from urllib3.util.url import Url, _encode_invalid_chars, parse_url
 from urllib3.util.util import to_bytes, to_str
@@ -923,6 +929,40 @@ def test_ssl_wrap_socket_sni_none_no_warn(self):
         context.wrap_socket.assert_called_once_with(sock, server_hostname=None)
         warn.assert_not_called()
 
+    @pytest.mark.parametrize(
+        "openssl_version, openssl_version_number, implementation_name, version_info, reliable",
+        [
+            # OpenSSL and Python OK -> reliable
+            ("OpenSSL 1.1.1l", 0x101010CF, "cpython", (3, 9, 3), True),
+            # Python OK -> reliable
+            ("OpenSSL 1.1.1", 0x10101000, "cpython", (3, 9, 3), True),
+            ("OpenSSL 1.1.1", 0x10101000, "pypy", (3, 6, 9), True),
+            ("LibreSSL 3.3.5", 0x101010CF, "pypy", (3, 6, 9), True),
+            # OpenSSL OK -> reliable
+            ("OpenSSL", 0x101010CF, "cpython", (3, 9, 2), True),
+            # unreliable
+            ("OpenSSL", 0x10101000, "cpython", (3, 9, 2), False),
+            ("LibreSSL", 0x101010CF, "cpython", (3, 9, 2), False),
+        ],
+    )
+    def test_is_has_never_check_common_name_reliable(
+        self,
+        openssl_version: str,
+        openssl_version_number: int,
+        implementation_name: str,
+        version_info: _TYPE_VERSION_INFO,
+        reliable: bool,
+    ) -> None:
+        assert (
+            _is_has_never_check_common_name_reliable(
+                openssl_version,
+                openssl_version_number,
+                implementation_name,
+                version_info,
+            )
+            == reliable
+        )
+
 
 idna_blocker = ImportBlocker("idna")
 module_stash = ModuleStash("urllib3")
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
@@ -978,8 +978,13 @@ def test_common_name_without_san_fails(self, no_san_server: ServerConfig) -> Non
             cert_reqs="CERT_REQUIRED",
             ca_certs=no_san_server.ca_certs,
         ) as https_pool:
-            with pytest.raises(MaxRetryError, match="no appropriate subjectAltName"):
+            with pytest.raises(
+                MaxRetryError,
+            ) as e:
                 https_pool.request("GET", "/")
+            assert "mismatch, certificate is not valid" in str(
+                e.value
+            ) or "no appropriate subjectAltName" in str(e.value)
 
     def test_strip_square_brackets_before_validating(
         self, ipv6_san_server: ServerConfig