diff --git a/Doc/library/asyncio-eventloop.rst b/Doc/library/asyncio-eventloop.rst index a4adb49a58fb36..58d26d1104f1b6 100644 --- a/Doc/library/asyncio-eventloop.rst +++ b/Doc/library/asyncio-eventloop.rst @@ -521,6 +521,11 @@ Opening network connections Added the *ssl_shutdown_timeout* parameter. + .. versionchanged:: next + 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:: 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 4262fe1eca2857..c68b08ca895fd0 100644 --- a/Doc/library/asyncio-stream.rst +++ b/Doc/library/asyncio-stream.rst @@ -365,6 +365,12 @@ StreamWriter .. versionadded:: 3.11 + .. versionchanged:: next + 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() Return ``True`` if the stream is closed or in the process of diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 79ea411abf2cbd..e85ed58bb74330 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1891,7 +1891,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:`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. In Python 3.13 and later a ``ValueError`` is raised instead. .. versionchanged:: 3.6 *session* argument was added. @@ -1900,6 +1904,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 7825cccba9b560..8aee67ba9ec7b8 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -866,6 +866,19 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, incoming, outgoing, server_side=False, server_hostname=None, session=None, context=None): + 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 server_hostname is None: + # Note: server_hostname='' is handled within _wrap_bio(). + 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, diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index f5f0afeab51c9e..33bafa963f52ab 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -70,6 +70,56 @@ 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) + + 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. + # 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() + server_hostname = None + + # Supplying no server_hostname warns with check_hostname enabled. + with self.assertWarnsRegex( + 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, + 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 aa8ce81db6688b..4b62d34f24f7e8 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -9,6 +9,7 @@ from test.support import socket_helper from test.support import threading_helper from test.support import warnings_helper +import contextlib import array import re import socket @@ -328,6 +329,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): @@ -1914,6 +1943,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.assertWarns(DeprecationWarning): + ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=None) with self.assertRaises(ValueError): ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), server_hostname="") @@ -1998,6 +2031,66 @@ 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 without a warning. Now + # a warning is emitted in this scenario. + client_context, _, hostname = testing_context() + self.assertTrue(client_context.check_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. + 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) + 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, + # 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 00000000000000..ce43a563f3a1df --- /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`, 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 emits +:exc:`DeprecationWarning` to indicate the hostname wasn't checked. +(In Python 3.13 and later, this raises :exc:`ValueError`.) 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 00000000000000..9afd6e9c454aaf --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst @@ -0,0 +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``, emitting +:exc:`DeprecationWarning` if *server_hostname* is missing. (This will raise +:exc:`ValueError` in Python 3.13 and later.)