diff --git a/5097.patch b/5097.patch new file mode 100644 index 0000000..ac3af0f --- /dev/null +++ b/5097.patch @@ -0,0 +1,133 @@ +From 8d2659ac68ec8d46dfd8c2fc0ab057b647ac7940 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= +Date: Wed, 1 Jul 2026 15:05:18 +0200 +Subject: [PATCH] Deal with ssl.PROTOCOL_TLSv1 removal from Python built with + OpenSSL 4+ + +https://github.com/python/cpython/commit/3364e7e62fa24d0e19133fb0f90b1c24ef1110c5 +removed ssl.PROTOCOL_TLSv1 when python is built with OpenSSL 4+. + +We are hitting this error in Fedora (where we already updated OpenSSL): + + ImportError while loading conftest '/builddir/build/BUILD/python-urllib3-2.7.0-build/urllib3-2.7.0/test/conftest.py'. + test/__init__.py:42: in + import urllib3.contrib.pyopenssl as pyopenssl + ../BUILDROOT/usr/lib/python3.15/site-packages/urllib3/contrib/pyopenssl.py:72: in + ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, + ^^^^^^^^^^^^^^^^^^ + E AttributeError: module 'ssl' has no attribute 'PROTOCOL_TLSv1'. Did you mean '.PROTOCOL_TLS' instead of '.PROTOCOL_TLSv1'? + +Used LLM to figure out what to use as a replacement protocol in tests. + +Assisted-By: Claude Opus 4.6 +--- + changelog/5097.bugfix.rst | 2 ++ + src/urllib3/contrib/pyopenssl.py | 4 +++- + test/test_ssl.py | 14 ++++++++------ + test/test_util.py | 24 +++++++++++++++--------- + 4 files changed, 28 insertions(+), 16 deletions(-) + create mode 100644 changelog/5097.bugfix.rst + +diff --git a/changelog/5097.bugfix.rst b/changelog/5097.bugfix.rst +new file mode 100644 +index 0000000000..73ed0ddaa1 +--- /dev/null ++++ b/changelog/5097.bugfix.rst +@@ -0,0 +1,2 @@ ++Fixed an ``AttributeError`` on Python built with OpenSSL 4+, where ++``ssl.PROTOCOL_TLSv1`` no longer exists. +diff --git a/src/urllib3/contrib/pyopenssl.py b/src/urllib3/contrib/pyopenssl.py +index 42781590ee..76a225b7bf 100644 +--- a/src/urllib3/contrib/pyopenssl.py ++++ b/src/urllib3/contrib/pyopenssl.py +@@ -68,9 +68,11 @@ class UnsupportedExtension(Exception): # type: ignore[no-redef] + _openssl_versions: dict[int, int] = { + util.ssl_.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, # type: ignore[attr-defined] + util.ssl_.PROTOCOL_TLS_CLIENT: OpenSSL.SSL.SSLv23_METHOD, # type: ignore[attr-defined] +- ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, + } + ++if hasattr(ssl, "PROTOCOL_TLSv1") and hasattr(OpenSSL.SSL, "TLSv1_METHOD"): ++ _openssl_versions[ssl.PROTOCOL_TLSv1] = OpenSSL.SSL.TLSv1_METHOD ++ + if hasattr(ssl, "PROTOCOL_TLSv1_1") and hasattr(OpenSSL.SSL, "TLSv1_1_METHOD"): + _openssl_versions[ssl.PROTOCOL_TLSv1_1] = OpenSSL.SSL.TLSv1_1_METHOD + +diff --git a/test/test_ssl.py b/test/test_ssl.py +index 6b1ab8589b..1e4063bf68 100644 +--- a/test/test_ssl.py ++++ b/test/test_ssl.py +@@ -176,19 +176,21 @@ def test_create_urllib3_context_default_ciphers( + + context.set_ciphers.assert_not_called() + ++ # PROTOCOL_TLS_SERVER is used as a stand-in for any non-default ssl_version. ++ # PROTOCOL_TLSv1/TLSv1_2 are unavailable when Python is built with OpenSSL 4+. + @pytest.mark.parametrize( + "kwargs", + [ + { +- "ssl_version": ssl.PROTOCOL_TLSv1, ++ "ssl_version": ssl.PROTOCOL_TLS_SERVER, + "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED, + }, + { +- "ssl_version": ssl.PROTOCOL_TLSv1, ++ "ssl_version": ssl.PROTOCOL_TLS_SERVER, + "ssl_maximum_version": ssl.TLSVersion.TLSv1, + }, + { +- "ssl_version": ssl.PROTOCOL_TLSv1, ++ "ssl_version": ssl.PROTOCOL_TLS_SERVER, + "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED, + "ssl_maximum_version": ssl.TLSVersion.MAXIMUM_SUPPORTED, + }, +@@ -229,10 +231,10 @@ def test_create_urllib3_context_ssl_version_and_ssl_min_max_version_no_warning( + @pytest.mark.parametrize( + "kwargs", + [ +- {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_minimum_version": None}, +- {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_maximum_version": None}, ++ {"ssl_version": ssl.PROTOCOL_TLS_SERVER, "ssl_minimum_version": None}, ++ {"ssl_version": ssl.PROTOCOL_TLS_SERVER, "ssl_maximum_version": None}, + { +- "ssl_version": ssl.PROTOCOL_TLSv1, ++ "ssl_version": ssl.PROTOCOL_TLS_SERVER, + "ssl_minimum_version": None, + "ssl_maximum_version": None, + }, +diff --git a/test/test_util.py b/test/test_util.py +index 8612ab5eaf..18c65f0312 100644 +--- a/test/test_util.py ++++ b/test/test_util.py +@@ -1216,15 +1216,21 @@ def test_resolve_cert_reqs( + ) -> None: + assert resolve_cert_reqs(candidate) == requirements + +- @pytest.mark.parametrize( +- "candidate, version", +- [ +- (ssl.PROTOCOL_TLSv1, ssl.PROTOCOL_TLSv1), +- ("PROTOCOL_TLSv1", ssl.PROTOCOL_TLSv1), +- ("TLSv1", ssl.PROTOCOL_TLSv1), +- (ssl.PROTOCOL_SSLv23, ssl.PROTOCOL_SSLv23), +- ], +- ) ++ candidate_version = [ ++ (ssl.PROTOCOL_SSLv23, ssl.PROTOCOL_SSLv23), ++ ("PROTOCOL_SSLv23", ssl.PROTOCOL_SSLv23), ++ ("SSLv23", ssl.PROTOCOL_SSLv23), ++ ] ++ if hasattr(ssl, "PROTOCOL_TLSv1"): ++ candidate_version.extend( ++ [ ++ (ssl.PROTOCOL_TLSv1, ssl.PROTOCOL_TLSv1), ++ ("PROTOCOL_TLSv1", ssl.PROTOCOL_TLSv1), ++ ("TLSv1", ssl.PROTOCOL_TLSv1), ++ ] ++ ) ++ ++ @pytest.mark.parametrize("candidate, version", candidate_version) + def test_resolve_ssl_version(self, candidate: int | str, version: int) -> None: + assert resolve_ssl_version(candidate) == version + diff --git a/5103.patch b/5103.patch new file mode 100644 index 0000000..c1b28d5 --- /dev/null +++ b/5103.patch @@ -0,0 +1,83 @@ +From 13fa1e033b37405b228599ee590af485dc3e38aa Mon Sep 17 00:00:00 2001 +From: Illia Volochii +Date: Sat, 18 Jul 2026 13:25:41 +0200 +Subject: [PATCH] Replace deprecated pyOpenSSL `X509.get_subject` and + `Context.set_passwd_cb` methods (#5103) + +Rebased on top of urllib3-2.7.0. +--- + src/urllib3/contrib/pyopenssl.py | 35 ++++++++++++++++++++++++++------- + 1 file changed, 28 insertions(+), 7 deletions(-) + +diff --git a/src/urllib3/contrib/pyopenssl.py b/src/urllib3/contrib/pyopenssl.py +index 76a225b7bf..a1b2c3d4e5 100644 +--- a/src/urllib3/contrib/pyopenssl.py ++++ b/src/urllib3/contrib/pyopenssl.py +@@ -42,6 +42,8 @@ + + import OpenSSL.SSL # type: ignore[import-not-found] + from cryptography import x509 ++from cryptography.hazmat.primitives.serialization import load_pem_private_key ++from cryptography.x509.oid import NameOID + + try: + from cryptography.x509 import UnsupportedExtension # type: ignore[attr-defined] +@@ -272,6 +274,15 @@ + return names + + ++def _get_common_name(peer_cert: X509) -> str | None: ++ """ ++ Given a pyOpenSSL certificate, return the subject's common name. ++ """ ++ cert = peer_cert.to_cryptography() ++ names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) ++ return typing.cast(str, names[0].value) if names else None ++ ++ + class WrappedSocket: + """API-compatibility wrapper for Python OpenSSL's Connection-class.""" + +@@ -396,7 +407,7 @@ + return OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, x509) # type: ignore[no-any-return] + + return { +- "subject": ((("commonName", x509.get_subject().CN),),), # type: ignore[dict-item] ++ "subject": ((("commonName", _get_common_name(x509)),),), # type: ignore[dict-item] + "subjectAltName": get_subj_alt_name(x509), + } + +@@ -482,16 +493,28 @@ + self, + certfile: str, + keyfile: str | None = None, +- password: str | None = None, ++ password: str | bytes | None = None, + ) -> None: + try: + self._ctx.use_certificate_chain_file(certfile) + if password is not None: + if not isinstance(password, bytes): +- password = password.encode("utf-8") # type: ignore[assignment] +- self._ctx.set_passwd_cb(lambda *_: password) +- self._ctx.use_privatekey_file(keyfile or certfile) +- except OpenSSL.SSL.Error as e: ++ password = password.encode("utf-8") ++ # pyOpenSSL added cryptography-key support in 24.3.0. ++ # Keep using the older password-callback path until 2026's ++ # versions because set_passwd_cb() became deprecated in 26.3.0. ++ if int(OpenSSL.__version__.split(".")[0]) >= 26: ++ with open(keyfile or certfile, "rb") as key_file: ++ private_key = load_pem_private_key(key_file.read(), password) ++ # cryptography's loader returns a wider private-key union ++ # than pyOpenSSL accepts, so we add `type: ignore` here. ++ self._ctx.use_privatekey(private_key) # type: ignore[arg-type] ++ else: ++ self._ctx.set_passwd_cb(lambda *_: password) ++ self._ctx.use_privatekey_file(keyfile or certfile) ++ else: ++ self._ctx.use_privatekey_file(keyfile or certfile) ++ except (OpenSSL.SSL.Error, TypeError, ValueError) as e: + raise ssl.SSLError(f"Unable to load certificate chain: {e!r}") from e + + def set_alpn_protocols(self, protocols: list[bytes | str]) -> None: diff --git a/python-urllib3-py315-ssl.patch b/python-urllib3-py315-ssl.patch deleted file mode 100644 index ec0d1fb..0000000 --- a/python-urllib3-py315-ssl.patch +++ /dev/null @@ -1,67 +0,0 @@ ---- a/src/urllib3/contrib/pyopenssl.py -+++ b/src/urllib3/contrib/pyopenssl.py -@@ -69,9 +69,11 @@ - _openssl_versions: dict[int, int] = { - util.ssl_.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, # type: ignore[attr-defined] - util.ssl_.PROTOCOL_TLS_CLIENT: OpenSSL.SSL.SSLv23_METHOD, # type: ignore[attr-defined] -- ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, - } - -+if hasattr(ssl, "PROTOCOL_TLSv1") and hasattr(OpenSSL.SSL, "TLSv1_METHOD"): -+ _openssl_versions[ssl.PROTOCOL_TLSv1] = OpenSSL.SSL.TLSv1_METHOD -+ - if hasattr(ssl, "PROTOCOL_TLSv1_1") and hasattr(OpenSSL.SSL, "TLSv1_1_METHOD"): - _openssl_versions[ssl.PROTOCOL_TLSv1_1] = OpenSSL.SSL.TLSv1_1_METHOD - ---- a/test/test_ssl.py -+++ b/test/test_ssl.py -@@ -173,15 +173,15 @@ - "kwargs", - [ - { -- "ssl_version": ssl.PROTOCOL_TLSv1, -+ "ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), - "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED, - }, - { -- "ssl_version": ssl.PROTOCOL_TLSv1, -+ "ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), - "ssl_maximum_version": ssl.TLSVersion.TLSv1, - }, - { -- "ssl_version": ssl.PROTOCOL_TLSv1, -+ "ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), - "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED, - "ssl_maximum_version": ssl.TLSVersion.MAXIMUM_SUPPORTED, - }, -@@ -222,10 +222,10 @@ - @pytest.mark.parametrize( - "kwargs", - [ -- {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_minimum_version": None}, -- {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_maximum_version": None}, -+ {"ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), "ssl_minimum_version": None}, -+ {"ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), "ssl_maximum_version": None}, - { -- "ssl_version": ssl.PROTOCOL_TLSv1, -+ "ssl_version": getattr(ssl, "PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1_2", 1)), - "ssl_minimum_version": None, - "ssl_maximum_version": None, - }, ---- a/test/test_util.py -+++ b/test/test_util.py -@@ -982,9 +982,11 @@ - @pytest.mark.parametrize( - "candidate, version", - [ -- (ssl.PROTOCOL_TLSv1, ssl.PROTOCOL_TLSv1), -- ("PROTOCOL_TLSv1", ssl.PROTOCOL_TLSv1), -- ("TLSv1", ssl.PROTOCOL_TLSv1), -+ *([ -+ (getattr(ssl, "PROTOCOL_TLSv1", None), getattr(ssl, "PROTOCOL_TLSv1", None)), -+ ("PROTOCOL_TLSv1", getattr(ssl, "PROTOCOL_TLSv1", None)), -+ ("TLSv1", getattr(ssl, "PROTOCOL_TLSv1", None)), -+ ] if hasattr(ssl, "PROTOCOL_TLSv1") else []), - (ssl.PROTOCOL_SSLv23, ssl.PROTOCOL_SSLv23), - ], - ) diff --git a/python-urllib3.spec b/python-urllib3.spec index b4683e8..49ec528 100644 --- a/python-urllib3.spec +++ b/python-urllib3.spec @@ -32,8 +32,10 @@ Source0: %{url}/archive/%{version}/urllib3-%{version}.tar.gz Source1: %{hypercorn_url}/archive/%{hypercorn_commit}/hypercorn-%{hypercorn_commit}.tar.gz # Deal with ssl.PROTOCOL_TLSv1 removal from Python built with OpenSSL 4+ -# https://github.com/urllib3/urllib3/pull/5097 -Patch: python-urllib3-py315-ssl.patch +Patch: https://github.com/urllib3/urllib3/pull/5097.patch +# Replace deprecated pyOpenSSL X509.get_subject and Context.set_passwd_cb methods +# https://github.com/urllib3/urllib3/pull/5103 rebased +Patch: 5103.patch BuildArch: noarch