https://github.com/python/cpython/commit/5867d4e4ae6d1062352baf6b497a4026e8578ccf
commit: 5867d4e4ae6d1062352baf6b497a4026e8578ccf
branch: 3.10
author: T. Wouters <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-10-01T01:37:59+01:00
summary:

[3.10] gh-156793: Validate SSLContext.wrap_bio() parameters like wrap_socket() 
(GH-158503) (#158533)

* [3.10] gh-156793: Validate SSLContext.wrap_bio() parameters like 
wrap_socket() (GH-158503) (GH-158513)

* [3.12] gh-156793: Validate SSLContext.wrap_bio() parameters like 
wrap_socket() (GH-158503)
(cherry picked from commit 1697ea386c707142555d98a1263176bbbc014a96)

* Raise a DeprecationWarning instead of ValueError
(cherry picked from commit 869069d52ce0efab2f8c38197e92cdaaa312f1ed)

Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: Seth Larson <[email protected]>
Co-authored-by: Bénédikt Tran <[email protected]>
Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: T. Wouters <[email protected]>

* gh-156793: Start the SSL handshake in the 3.10 hostname validation tests

---------

Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: Seth Larson <[email protected]>
Co-authored-by: Bénédikt Tran <[email protected]>
Co-authored-by: Pablo Galindo Salgado <[email protected]>

files:
A Misc/NEWS.d/next/Security/2026-09-16-14-05-19.gh-issue-156793.Qa1T5Z.rst
A Misc/NEWS.d/next/Security/2026-09-23-11-34-30.gh-issue-156793.zC_AjF.rst
M Doc/library/asyncio-eventloop.rst
M Doc/library/ssl.rst
M Lib/ssl.py
M Lib/test/test_asyncio/test_sslproto.py
M Lib/test/test_ssl.py

diff --git a/Doc/library/asyncio-eventloop.rst 
b/Doc/library/asyncio-eventloop.rst
index 9e59b5b0dc20248..d9b183733bd8bf9 100644
--- a/Doc/library/asyncio-eventloop.rst
+++ b/Doc/library/asyncio-eventloop.rst
@@ -505,6 +505,11 @@ Opening network connections
 
       For more information: https://tools.ietf.org/html/rfc6555
 
+   .. 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/ssl.rst b/Doc/library/ssl.rst
index 54af5b3409a550b..9d0663595717adb 100644
--- a/Doc/library/ssl.rst
+++ b/Doc/library/ssl.rst
@@ -1901,7 +1901,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.
@@ -1910,6 +1914,13 @@ to speed up repeated connections from the same clients.
       The method returns on 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 f386fa7831528f3..84c659f1dc0a34e 100644
--- a/Lib/ssl.py
+++ b/Lib/ssl.py
@@ -862,6 +862,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 f7411a8142cc87a..ad352bb8757fe45 100644
--- a/Lib/test/test_asyncio/test_sslproto.py
+++ b/Lib/test/test_asyncio/test_sslproto.py
@@ -71,6 +71,63 @@ 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.connection_lost, None)
+        ssl_proto.connection_made(mock.Mock())
+
+    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'):
+            ssl_proto = sslproto.SSLProtocol(
+                self.loop, app_proto, sslcontext, waiter)
+            self.addCleanup(ssl_proto.connection_lost, None)
+            ssl_proto.connection_made(mock.Mock())
+
+        with self.assertWarnsRegex(
+                DeprecationWarning,
+                'check_hostname requires server_hostname'):
+            ssl_proto = sslproto.SSLProtocol(
+                self.loop, app_proto, sslcontext, waiter,
+                server_hostname=server_hostname)
+            self.addCleanup(ssl_proto.connection_lost, None)
+            ssl_proto.connection_made(mock.Mock())
+
+        # 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.connection_lost, None)
+        ssl_proto.connection_made(mock.Mock())
+
+        ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext,
+                             waiter,
+                             server_hostname=server_hostname)
+        self.addCleanup(ssl_proto.connection_lost, None)
+        ssl_proto.connection_made(mock.Mock())
+
     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 28f74b6b9fcad62..e9af4c4b084acce 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 re
 import socket
 import select
@@ -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):
@@ -1888,6 +1917,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="")
@@ -1968,6 +2001,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 000000000000000..ce43a563f3a1df0
--- /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 000000000000000..9afd6e9c454aaff
--- /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() <asyncio.loop.start_tls>` and
+:meth:`loop.create_connection() <asyncio.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.)

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to