From 9a9bff5a8db2b42202cb12c6cd2cb838a206a918 Mon Sep 17 00:00:00 2001 From: Seth Larson Date: Wed, 30 Sep 2026 10:31:18 -0500 Subject: [PATCH 1/7] [3.12] gh-156793: Validate SSLContext.wrap_bio() parameters like wrap_socket() (GH-158503) (cherry picked from commit 1697ea386c707142555d98a1263176bbbc014a96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Seth Larson Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/library/asyncio-eventloop.rst | 4 + Doc/library/asyncio-stream.rst | 4 + Doc/library/ssl.rst | 13 ++- Lib/ssl.py | 27 ++++-- Lib/test/test_asyncio/test_sslproto.py | 44 +++++++++ Lib/test/test_ssl.py | 91 +++++++++++++++++++ ...-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst | 9 ++ ...-09-23-11-34-30.gh-issue-156793.zC_AjF.rst | 4 + 8 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst create mode 100644 Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst diff --git a/Doc/library/asyncio-eventloop.rst b/Doc/library/asyncio-eventloop.rst index 710ee17b87bf09c..d8f8b2409e692d2 100644 --- a/Doc/library/asyncio-eventloop.rst +++ b/Doc/library/asyncio-eventloop.rst @@ -544,6 +544,10 @@ Opening network connections .. versionchanged:: 3.12 *all_errors* was added. + .. versionchanged:: next + Raises a ``ValueError`` if ``ssl.check_hostname`` is ``True`` + and ``server_hostname`` is not supplied. + .. seealso:: The :func:`open_connection` function is a high-level alternative diff --git a/Doc/library/asyncio-stream.rst b/Doc/library/asyncio-stream.rst index cb381d76e91fbf2..8fe9e66b0ba4700 100644 --- a/Doc/library/asyncio-stream.rst +++ b/Doc/library/asyncio-stream.rst @@ -382,6 +382,10 @@ StreamWriter .. versionchanged:: 3.12 Added the *ssl_shutdown_timeout* parameter. + .. versionchanged:: next + Raises a ``ValueError`` if ``sslcontext.check_hostname`` is ``True`` + and ``server_hostname`` is not supplied. + .. method:: is_closing() diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 67360a5079fa057..330f7d767ca7562 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1809,7 +1809,11 @@ to speed up repeated connections from the same clients. outgoing BIO. The *server_side*, *server_hostname* and *session* parameters have the - same meaning as in :meth:`SSLContext.wrap_socket`. + same meaning as in :meth:`SSLContext.wrap_socket`, and are validated in + the same way: in particular a :exc:`ValueError` is raised when + :attr:`~SSLContext.check_hostname` is enabled but no *server_hostname* is + given, since there would be no name to match the peer's certificate + against. .. versionchanged:: 3.6 *session* argument was added. @@ -1818,6 +1822,13 @@ to speed up repeated connections from the same clients. The method returns an instance of :attr:`SSLContext.sslobject_class` instead of hard-coded :class:`SSLObject`. + .. versionchanged:: next + The *server_side*, *server_hostname* and *session* parameters are now + validated as :meth:`SSLContext.wrap_socket` validates them. Previously + a context with :attr:`~SSLContext.check_hostname` enabled and no + *server_hostname* was accepted, and verified the certificate chain but + never the peer's identity. + .. attribute:: SSLContext.sslobject_class The return type of :meth:`SSLContext.wrap_bio`, defaults to diff --git a/Lib/ssl.py b/Lib/ssl.py index 42ebb8ed38466d2..393104c95637e09 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -373,6 +373,20 @@ def _ipaddress_match(cert_ipaddress, host_ip): return ip == host_ip +def _check_sslobject_params(server_side, context=None, server_hostname=None, session=None): + """Raises a ValueError if SSLObject._create() parameters aren't valid. + """ + if server_side: + if server_hostname: + raise ValueError("server_hostname can only be specified " + "in client mode") + if session is not None: + raise ValueError("session can only be specified in " + "client mode") + if context.check_hostname and not server_hostname: + raise ValueError("check_hostname requires server_hostname") + + DefaultVerifyPaths = namedtuple("DefaultVerifyPaths", "cafile capath openssl_cafile_env openssl_cafile openssl_capath_env " "openssl_capath") @@ -803,6 +817,8 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, incoming, outgoing, server_side=False, server_hostname=None, session=None, context=None): + _check_sslobject_params(server_side=server_side, context=context, + server_hostname=server_hostname, session=session) self = cls.__new__(cls) sslobj = context._wrap_bio( incoming, outgoing, server_side=server_side, @@ -958,15 +974,8 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True, context=None, session=None): if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM: raise NotImplementedError("only stream sockets are supported") - if server_side: - if server_hostname: - raise ValueError("server_hostname can only be specified " - "in client mode") - if session is not None: - raise ValueError("session can only be specified in " - "client mode") - if context.check_hostname and not server_hostname: - raise ValueError("check_hostname requires server_hostname") + _check_sslobject_params(server_side=server_side, context=context, + server_hostname=server_hostname, session=session) sock_timeout = sock.gettimeout() kwargs = dict( diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index 761904c5146b6a9..f82075e10348876 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -70,6 +70,50 @@ def test_handshake_timeout_negative(self): sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter, ssl_handshake_timeout=-10) + def test_check_hostname_accepts_server_hostname(self): + # Supplying a server_hostname succeeds with check_hostname enabled. + sslcontext = test_utils.simple_client_sslcontext(disable_verify=False) + sslcontext.check_hostname = True + app_proto = mock.Mock() + waiter = mock.Mock() + + # No ValueError is raised from SSLProtocol with 'server_hostname'. + ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter, + server_hostname='example.org') + self.addCleanup(ssl_proto._app_transport.close) + + @support.subTests("server_hostname", [None, '']) + def test_check_hostname_requires_server_hostname(self, server_hostname): + # A caller-supplied context asking for hostname checking used to be + # taken through wrap_bio() with no name to check against, verifying + # the certificate chain but never the peer's identity. + # loop.start_tls() defaults server_hostname to None, and + # loop.create_connection() turns server_hostname='' into None here, + # so both reached that state. + sslcontext = test_utils.simple_client_sslcontext(disable_verify=False) + sslcontext.check_hostname = True + app_proto = mock.Mock() + waiter = mock.Mock() + + # Supplying an empty server_hostname fails with check_hostname enabled. + with self.assertRaisesRegex( + ValueError, + 'check_hostname requires server_hostname'): + sslproto.SSLProtocol(self.loop, app_proto, sslcontext, + waiter, + server_hostname=server_hostname) + + # Disabling check_hostname allows for an empty or unset server_hostname. + sslcontext.check_hostname = False + + ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter) + self.addCleanup(ssl_proto._app_transport.close) + + ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext, + waiter, + server_hostname=server_hostname) + self.addCleanup(ssl_proto._app_transport.close) + def test_eof_received_waiter(self): waiter = self.loop.create_future() ssl_proto = self.ssl_protocol(waiter=waiter) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index b13e37d0cd18ee9..69321dc917f9709 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -10,6 +10,7 @@ from test.support import threading_helper from test.support import warnings_helper from test.support import asyncore +import contextlib import array import re import socket @@ -322,6 +323,34 @@ def testing_context(server_cert=SIGNED_CERTFILE, *, server_chain=True): return client_context, server_context, hostname +def connected_bio_pair(client_context, server_context, hostname, max_retry=5): + """Handshake a client and a server SSLObject against each other. + + Everything happens in memory, so this needs no socket and no thread. + Returns the two objects followed by their four BIOs, in the order + client, server, c_in, c_out, s_in, s_out. + """ + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_context.wrap_bio(c_in, c_out, server_hostname=hostname) + server = server_context.wrap_bio(s_in, s_out, server_side=True) + + # Loop on the handshake for a bit to get it settled + for _ in range(max_retry): + with contextlib.suppress(ssl.SSLWantReadError): + client.do_handshake() + if c_out.pending: + s_in.write(c_out.read()) + with contextlib.suppress(ssl.SSLWantReadError): + server.do_handshake() + if s_out.pending: + c_in.write(s_out.read()) + # Now the handshakes should be complete (don't raise WantReadError) + client.do_handshake() + server.do_handshake() + return client, server, c_in, c_out, s_in, s_out + + class BasicSocketTests(unittest.TestCase): def test_constants(self): @@ -1692,6 +1721,10 @@ def test_subclass(self): def test_bad_server_hostname(self): ctx = ssl.create_default_context() + # Omitting the name entirely is bad too: this context checks it. + with self.assertRaises(ValueError): + ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=None) with self.assertRaises(ValueError): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="") @@ -1776,6 +1809,64 @@ def test_private_init(self): with self.assertRaisesRegex(TypeError, "public constructor"): ssl.SSLObject(bio, bio) + def test_check_hostname_requires_server_hostname(self): + # wrap_bio() used to accept a context asking for hostname checking + # without a name to check against, and then verify the certificate + # chain but never the peer's identity, with check_hostname still + # reporting True and nothing reporting the check had been skipped. + # It must refuse that call, as wrap_socket() already did. + client_context, _, hostname = testing_context() + self.assertTrue(client_context.check_hostname) + + for server_hostname in (None, ""): + with self.subTest(server_hostname=server_hostname): + with self.assertRaisesRegex( + ValueError, + "check_hostname requires server_hostname"): + client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=server_hostname) + # The sibling constructor refuses the very same call. + with socket.socket() as sock: + with self.assertRaisesRegex( + ValueError, + "check_hostname requires server_hostname"): + client_context.wrap_socket( + sock, server_hostname=server_hostname) + + # A name was all that was missing. + client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=hostname) + + # Asking for no hostname check remains a way to say so explicitly. + context = make_test_context() + self.assertFalse(context.check_hostname) + context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO()) + + def test_server_side_bad_params(self): + # A server neither sends a hostname nor resumes a client's session, + # so wrap_bio() rejects both in server mode like wrap_socket() + client_context, server_context, hostname = testing_context() + + with self.assertRaisesRegex( + ValueError, + "server_hostname can only be specified in client mode"): + server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_side=True, + server_hostname=hostname) + + client, server, *_ = connected_bio_pair( + client_context, server_context, hostname) + session = client.session + self.assertIsNotNone(session) + with self.assertRaisesRegex( + ValueError, "session can only be specified in client mode"): + server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_side=True, session=session) + + # Neither argument is what a server passes, so this still works. + server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_side=True) + def test_unwrap(self): client_ctx, server_ctx, hostname = testing_context() c_in = ssl.MemoryBIO() diff --git a/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst b/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst new file mode 100644 index 000000000000000..2a521dbc9dc4e68 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst @@ -0,0 +1,9 @@ +:meth:`ssl.SSLContext.wrap_bio` now validates its *server_side*, +*server_hostname* and *session* arguments similar to +:meth:`ssl.SSLContext.wrap_socket`. + +In particular, a context with :attr:`~ssl.SSLContext.check_hostname` enabled +and no *server_hostname* passed to :meth:`!wrap_bio` now raises :exc:`ValueError` +instead of completing a handshake that verified the certificate chain +without verifying the peer's identity, with no indication that the +check had been skipped. diff --git a/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst b/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst new file mode 100644 index 000000000000000..42a31b3c28f5ce1 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst @@ -0,0 +1,4 @@ +:mod:`asyncio`: :meth:`loop.start_tls() ` and +:meth:`loop.create_connection() ` now +validate the *server_hostname* argument if an :class:`ssl.SSLContext` is +passed with *check_hostname* set to ``True``. From 1712d5b2e7a2fc021354bb925a9da233d6d9acba Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:01:12 +0300 Subject: [PATCH 2/7] Replace make_test_context(), not in this branch --- Lib/test/test_ssl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 69321dc917f9709..aa79ccdfae3d0ec 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1838,7 +1838,8 @@ def test_check_hostname_requires_server_hostname(self): server_hostname=hostname) # Asking for no hostname check remains a way to say so explicitly. - context = make_test_context() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False self.assertFalse(context.check_hostname) context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO()) From ca99e007fe02be59afc639f3735e381a51c1e891 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 30 Sep 2026 14:13:32 -0500 Subject: [PATCH 3/7] Raise a DeprecationWarning instead of ValueError --- Doc/library/asyncio-eventloop.rst | 5 ++-- Doc/library/asyncio-stream.rst | 5 ++-- Doc/library/ssl.rst | 4 +-- Lib/ssl.py | 39 ++++++++++++++------------ Lib/test/test_asyncio/test_sslproto.py | 4 +-- Lib/test/test_ssl.py | 17 ++++++----- 6 files changed, 39 insertions(+), 35 deletions(-) diff --git a/Doc/library/asyncio-eventloop.rst b/Doc/library/asyncio-eventloop.rst index d8f8b2409e692d2..a84583bc9d83958 100644 --- a/Doc/library/asyncio-eventloop.rst +++ b/Doc/library/asyncio-eventloop.rst @@ -545,8 +545,9 @@ Opening network connections *all_errors* was added. .. versionchanged:: next - Raises a ``ValueError`` if ``ssl.check_hostname`` is ``True`` - and ``server_hostname`` is not supplied. + Raises a ``DeprecationWarning`` if ``ssl.check_hostname`` is ``True`` + and ``server_hostname`` is not supplied. In Python 3.13 and + later a ``ValueError`` is raised instead. .. seealso:: diff --git a/Doc/library/asyncio-stream.rst b/Doc/library/asyncio-stream.rst index 8fe9e66b0ba4700..f826c0511ff7852 100644 --- a/Doc/library/asyncio-stream.rst +++ b/Doc/library/asyncio-stream.rst @@ -383,8 +383,9 @@ StreamWriter Added the *ssl_shutdown_timeout* parameter. .. versionchanged:: next - Raises a ``ValueError`` if ``sslcontext.check_hostname`` is ``True`` - and ``server_hostname`` is not supplied. + Raises a ``DeprecationWarning`` if ``ssl.check_hostname`` is ``True`` + and ``server_hostname`` is not supplied. In Python 3.13 and + later a ``ValueError`` is raised instead. .. method:: is_closing() diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 330f7d767ca7562..adf1a26c6a916c9 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1810,10 +1810,10 @@ to speed up repeated connections from the same clients. The *server_side*, *server_hostname* and *session* parameters have the same meaning as in :meth:`SSLContext.wrap_socket`, and are validated in - the same way: in particular a :exc:`ValueError` is raised when + the same way: in particular a :exc:`DeprecationWarning` is raised when :attr:`~SSLContext.check_hostname` is enabled but no *server_hostname* is given, since there would be no name to match the peer's certificate - against. + against. In Python 3.13 and later a ``ValueError`` is raised instead. .. versionchanged:: 3.6 *session* argument was added. diff --git a/Lib/ssl.py b/Lib/ssl.py index 393104c95637e09..75eae8eedb322c4 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -373,20 +373,6 @@ def _ipaddress_match(cert_ipaddress, host_ip): return ip == host_ip -def _check_sslobject_params(server_side, context=None, server_hostname=None, session=None): - """Raises a ValueError if SSLObject._create() parameters aren't valid. - """ - if server_side: - if server_hostname: - raise ValueError("server_hostname can only be specified " - "in client mode") - if session is not None: - raise ValueError("session can only be specified in " - "client mode") - if context.check_hostname and not server_hostname: - raise ValueError("check_hostname requires server_hostname") - - DefaultVerifyPaths = namedtuple("DefaultVerifyPaths", "cafile capath openssl_cafile_env openssl_cafile openssl_capath_env " "openssl_capath") @@ -817,8 +803,18 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, incoming, outgoing, server_side=False, server_hostname=None, session=None, context=None): - _check_sslobject_params(server_side=server_side, context=context, - server_hostname=server_hostname, session=session) + if server_side: + if server_hostname: + raise ValueError("server_hostname can only be specified " + "in client mode") + if session is not None: + raise ValueError("session can only be specified in " + "client mode") + if context.check_hostname and not server_hostname: + warnings.warn("check_hostname requires server_hostname", + category=DeprecationWarning, + stacklevel=3) + self = cls.__new__(cls) sslobj = context._wrap_bio( incoming, outgoing, server_side=server_side, @@ -974,8 +970,15 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True, context=None, session=None): if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM: raise NotImplementedError("only stream sockets are supported") - _check_sslobject_params(server_side=server_side, context=context, - server_hostname=server_hostname, session=session) + if server_side: + if server_hostname: + raise ValueError("server_hostname can only be specified " + "in client mode") + if session is not None: + raise ValueError("session can only be specified in " + "client mode") + if context.check_hostname and not server_hostname: + raise ValueError("check_hostname requires server_hostname") sock_timeout = sock.gettimeout() kwargs = dict( diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index f82075e10348876..55c555fe8bcca0d 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -96,8 +96,8 @@ def test_check_hostname_requires_server_hostname(self, server_hostname): waiter = mock.Mock() # Supplying an empty server_hostname fails with check_hostname enabled. - with self.assertRaisesRegex( - ValueError, + with self.assertWarnsRegex( + UserWarning, 'check_hostname requires server_hostname'): sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter, diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index aa79ccdfae3d0ec..40ad9e2dc461c12 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1722,10 +1722,10 @@ def test_subclass(self): def test_bad_server_hostname(self): ctx = ssl.create_default_context() # Omitting the name entirely is bad too: this context checks it. - with self.assertRaises(ValueError): + with self.assertWarns(DeprecationWarning): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname=None) - with self.assertRaises(ValueError): + with self.assertRaises(DeprecationWarning): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="") with self.assertRaises(ValueError): @@ -1812,23 +1812,22 @@ def test_private_init(self): def test_check_hostname_requires_server_hostname(self): # wrap_bio() used to accept a context asking for hostname checking # without a name to check against, and then verify the certificate - # chain but never the peer's identity, with check_hostname still - # reporting True and nothing reporting the check had been skipped. - # It must refuse that call, as wrap_socket() already did. + # chain but never the peer's identity without a warning. Now + # a warning is emitted in this scenario. client_context, _, hostname = testing_context() self.assertTrue(client_context.check_hostname) for server_hostname in (None, ""): with self.subTest(server_hostname=server_hostname): - with self.assertRaisesRegex( - ValueError, + with self.assertWarnsRegex( + DeprecationWarning, "check_hostname requires server_hostname"): client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname=server_hostname) # The sibling constructor refuses the very same call. with socket.socket() as sock: - with self.assertRaisesRegex( - ValueError, + with self.assertWarnsRegex( + DeprecationWarning, "check_hostname requires server_hostname"): client_context.wrap_socket( sock, server_hostname=server_hostname) From ff797896110b54d9a59dbe1a6a63d9d55baa331e Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 30 Sep 2026 14:59:35 -0500 Subject: [PATCH 4/7] Fix tests and comments, server_hostname='' still raises ValueError --- Lib/test/test_asyncio/test_sslproto.py | 14 +++++++--- Lib/test/test_ssl.py | 38 ++++++++++++++------------ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index 55c555fe8bcca0d..c8351e107e0a1f2 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -82,8 +82,7 @@ def test_check_hostname_accepts_server_hostname(self): server_hostname='example.org') self.addCleanup(ssl_proto._app_transport.close) - @support.subTests("server_hostname", [None, '']) - def test_check_hostname_requires_server_hostname(self, server_hostname): + def test_check_hostname_requires_server_hostname(self): # A caller-supplied context asking for hostname checking used to be # taken through wrap_bio() with no name to check against, verifying # the certificate chain but never the peer's identity. @@ -94,10 +93,17 @@ def test_check_hostname_requires_server_hostname(self, server_hostname): sslcontext.check_hostname = True app_proto = mock.Mock() waiter = mock.Mock() + server_hostname = None - # Supplying an empty server_hostname fails with check_hostname enabled. + # Supplying no server_hostname warns with check_hostname enabled. with self.assertWarnsRegex( - UserWarning, + DeprecationWarning, + 'check_hostname requires server_hostname'): + sslproto.SSLProtocol(self.loop, app_proto, sslcontext, + waiter) + + with self.assertWarnsRegex( + DeprecationWarning, 'check_hostname requires server_hostname'): sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter, diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 40ad9e2dc461c12..019230b8da489c6 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1725,7 +1725,7 @@ def test_bad_server_hostname(self): with self.assertWarns(DeprecationWarning): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname=None) - with self.assertRaises(DeprecationWarning): + with self.assertRaises(ValueError): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="") with self.assertRaises(ValueError): @@ -1817,30 +1817,32 @@ def test_check_hostname_requires_server_hostname(self): client_context, _, hostname = testing_context() self.assertTrue(client_context.check_hostname) - for server_hostname in (None, ""): - with self.subTest(server_hostname=server_hostname): - with self.assertWarnsRegex( - DeprecationWarning, - "check_hostname requires server_hostname"): - client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), - server_hostname=server_hostname) - # The sibling constructor refuses the very same call. - with socket.socket() as sock: - with self.assertWarnsRegex( - DeprecationWarning, - "check_hostname requires server_hostname"): - client_context.wrap_socket( - sock, server_hostname=server_hostname) + server_hostname = None + with self.assertWarnsRegex( + DeprecationWarning, + "check_hostname requires server_hostname"): + client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=server_hostname) + # The sibling constructor refuses the very same call, but with + # a ValueError instead of DeprecationWarning. + with socket.socket() as sock: + with self.assertRaisesRegex( + ValueError, + "check_hostname requires server_hostname"): + client_context.wrap_socket( + sock, server_hostname=server_hostname) # A name was all that was missing. - client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), - server_hostname=hostname) + with warnings_helper.check_no_warnings(self): + client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=hostname) # Asking for no hostname check remains a way to say so explicitly. context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.check_hostname = False self.assertFalse(context.check_hostname) - context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO()) + with warnings_helper.check_no_warnings(self): + context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO()) def test_server_side_bad_params(self): # A server neither sends a hostname nor resumes a client's session, From 2c440e8af962cad3c344f1da7cf4ff03d487e9d9 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 30 Sep 2026 15:24:44 -0500 Subject: [PATCH 5/7] Only emit new warning for server_hostname=None --- Lib/ssl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/ssl.py b/Lib/ssl.py index 75eae8eedb322c4..f243c1c406f2703 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -810,7 +810,8 @@ def _create(cls, incoming, outgoing, server_side=False, if session is not None: raise ValueError("session can only be specified in " "client mode") - if context.check_hostname and not server_hostname: + if context.check_hostname and server_hostname is None: + # Note: server_hostname='' is handled within _wrap_bio(). warnings.warn("check_hostname requires server_hostname", category=DeprecationWarning, stacklevel=3) From d472f491698e4be5e0b1168fc9a4da27484ef40f Mon Sep 17 00:00:00 2001 From: "T. Wouters" Date: Wed, 30 Sep 2026 22:55:47 +0200 Subject: [PATCH 6/7] Update Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst --- .../2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst b/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst index 2a521dbc9dc4e68..ce43a563f3a1df0 100644 --- a/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst +++ b/Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst @@ -1,9 +1,9 @@ :meth:`ssl.SSLContext.wrap_bio` now validates its *server_side*, *server_hostname* and *session* arguments similar to -:meth:`ssl.SSLContext.wrap_socket`. +:meth:`ssl.SSLContext.wrap_socket`, but for backward compatiblity reasons +emits :exc:`DeprecationWarning` instead of :exc:`ValueError`. In particular, a context with :attr:`~ssl.SSLContext.check_hostname` enabled -and no *server_hostname* passed to :meth:`!wrap_bio` now raises :exc:`ValueError` -instead of completing a handshake that verified the certificate chain -without verifying the peer's identity, with no indication that the -check had been skipped. +and no *server_hostname* passed to :meth:`!wrap_bio` now emits +:exc:`DeprecationWarning` to indicate the hostname wasn't checked. +(In Python 3.13 and later, this raises :exc:`ValueError`.) From 196266978651d67efdfe451d3b773f6006274369 Mon Sep 17 00:00:00 2001 From: "T. Wouters" Date: Wed, 30 Sep 2026 22:55:57 +0200 Subject: [PATCH 7/7] Update Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst --- .../Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst b/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst index 42a31b3c28f5ce1..9afd6e9c454aaff 100644 --- a/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst +++ b/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst @@ -1,4 +1,6 @@ :mod:`asyncio`: :meth:`loop.start_tls() ` and :meth:`loop.create_connection() ` now validate the *server_hostname* argument if an :class:`ssl.SSLContext` is -passed with *check_hostname* set to ``True``. +passed with *check_hostname* set to ``True``, emitting +:exc:`DeprecationWarning` if *server_hostname* is missing. (This will raise +:exc:`ValueError` in Python 3.13 and later.)