Use SSLContext.check_hostname when SSLContext.hostname_checks_common_name is functional
MODIFY
.github/workflows/ci.yml
.github/workflows/ci.yml
@@ -45,6 +45,11 @@ jobs:
os: ubuntu-latest
experimental: false
nox-session: test_brotlipy
+ # Test CPython with a broken hostname_checks_common_name (the fix is in 3.9.3)
+ - python-version: "3.9.2"
+ os: ubuntu-latest
+ experimental: false
+ nox-session: test-3.9
runs-on: ${{ matrix.os }}
name: ${{ fromJson('{"macos-latest":"macOS","windows-latest":"Windows","ubuntu-latest":"Ubuntu"}')[matrix.os] }} ${{ matrix.python-version }} ${{ matrix.nox-session}}

MODIFY
src/urllib3/connection.py
src/urllib3/connection.py
@@ -483,10 +483,7 @@ class HTTPSConnection(HTTPConnection):
# 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

MODIFY
src/urllib3/util/ssl_.py
src/urllib3/util/ssl_.py
@@ -19,6 +19,8 @@ IS_SECURETRANSPORT = False
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 @@ try: # Do we have ssl at all?
)
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"):

MODIFY
test/test_util.py
test/test_util.py
@@ -26,7 +26,13 @@ from urllib3.util.connection import _has_ipv6, allowed_gai_family, create_connec
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
@@ -954,6 +960,40 @@ class TestUtilSSL:
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")

MODIFY
test/with_dummyserver/test_https.py
test/with_dummyserver/test_https.py
@@ -978,8 +978,13 @@ class TestHTTPS_Hostname:
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

Use SSLContext.check_hostname when SSLContext.hostname_checks_common_name is functional