Skip to content

Commit f4e43ba

Browse files
miss-islingtonsethmlarsonpicnixzhugovk
authored
[3.13] gh-156793: Validate SSLContext.wrap_bio() parameters like wrap_socket() (GH-158503) (#158510)
Co-authored-by: Seth Larson <seth@python.org> 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>
1 parent d8717ed commit f4e43ba

8 files changed

Lines changed: 186 additions & 10 deletions

File tree

‎Doc/library/asyncio-eventloop.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,10 @@ Opening network connections
576576
.. versionchanged:: 3.12
577577
*all_errors* was added.
578578

579+
.. versionchanged:: next
580+
Raises a ``ValueError`` if ``ssl.check_hostname`` is ``True``
581+
and ``server_hostname`` is not supplied.
582+
579583
.. seealso::
580584

581585
The :func:`open_connection` function is a high-level alternative

‎Doc/library/asyncio-stream.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ StreamWriter
418418
.. versionchanged:: 3.12
419419
Added the *ssl_shutdown_timeout* parameter.
420420

421+
.. versionchanged:: next
422+
Raises a ``ValueError`` if ``sslcontext.check_hostname`` is ``True``
423+
and ``server_hostname`` is not supplied.
424+
421425

422426
.. method:: is_closing()
423427

‎Doc/library/ssl.rst‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1870,7 +1870,11 @@ to speed up repeated connections from the same clients.
18701870
outgoing BIO.
18711871

18721872
The *server_side*, *server_hostname* and *session* parameters have the
1873-
same meaning as in :meth:`SSLContext.wrap_socket`.
1873+
same meaning as in :meth:`SSLContext.wrap_socket`, and are validated in
1874+
the same way: in particular a :exc:`ValueError` is raised when
1875+
:attr:`~SSLContext.check_hostname` is enabled but no *server_hostname* is
1876+
given, since there would be no name to match the peer's certificate
1877+
against.
18741878

18751879
.. versionchanged:: 3.6
18761880
*session* argument was added.
@@ -1879,6 +1883,13 @@ to speed up repeated connections from the same clients.
18791883
The method returns an instance of :attr:`SSLContext.sslobject_class`
18801884
instead of hard-coded :class:`SSLObject`.
18811885

1886+
.. versionchanged:: next
1887+
The *server_side*, *server_hostname* and *session* parameters are now
1888+
validated as :meth:`SSLContext.wrap_socket` validates them. Previously
1889+
a context with :attr:`~SSLContext.check_hostname` enabled and no
1890+
*server_hostname* was accepted, and verified the certificate chain but
1891+
never the peer's identity.
1892+
18821893
.. attribute:: SSLContext.sslobject_class
18831894

18841895
The return type of :meth:`SSLContext.wrap_bio`, defaults to

‎Lib/ssl.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,20 @@ def _ipaddress_match(cert_ipaddress, host_ip):
373373
return ip == host_ip
374374

375375

376+
def _check_sslobject_params(server_side, context=None, server_hostname=None, session=None):
377+
"""Raises a ValueError if SSLObject._create() parameters aren't valid.
378+
"""
379+
if server_side:
380+
if server_hostname:
381+
raise ValueError("server_hostname can only be specified "
382+
"in client mode")
383+
if session is not None:
384+
raise ValueError("session can only be specified in "
385+
"client mode")
386+
if context.check_hostname and not server_hostname:
387+
raise ValueError("check_hostname requires server_hostname")
388+
389+
376390
DefaultVerifyPaths = namedtuple("DefaultVerifyPaths",
377391
"cafile capath openssl_cafile_env openssl_cafile openssl_capath_env "
378392
"openssl_capath")
@@ -815,6 +829,8 @@ def __init__(self, *args, **kwargs):
815829
@classmethod
816830
def _create(cls, incoming, outgoing, server_side=False,
817831
server_hostname=None, session=None, context=None):
832+
_check_sslobject_params(server_side=server_side, context=context,
833+
server_hostname=server_hostname, session=session)
818834
self = cls.__new__(cls)
819835
sslobj = context._wrap_bio(
820836
incoming, outgoing, server_side=server_side,
@@ -995,15 +1011,8 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
9951011
context=None, session=None):
9961012
if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM:
9971013
raise NotImplementedError("only stream sockets are supported")
998-
if server_side:
999-
if server_hostname:
1000-
raise ValueError("server_hostname can only be specified "
1001-
"in client mode")
1002-
if session is not None:
1003-
raise ValueError("session can only be specified in "
1004-
"client mode")
1005-
if context.check_hostname and not server_hostname:
1006-
raise ValueError("check_hostname requires server_hostname")
1014+
_check_sslobject_params(server_side=server_side, context=context,
1015+
server_hostname=server_hostname, session=session)
10071016

10081017
sock_timeout = sock.gettimeout()
10091018
kwargs = dict(

‎Lib/test/test_asyncio/test_sslproto.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,50 @@ def test_handshake_timeout_negative(self):
7070
sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter,
7171
ssl_handshake_timeout=-10)
7272

73+
def test_check_hostname_accepts_server_hostname(self):
74+
# Supplying a server_hostname succeeds with check_hostname enabled.
75+
sslcontext = test_utils.simple_client_sslcontext(disable_verify=False)
76+
sslcontext.check_hostname = True
77+
app_proto = mock.Mock()
78+
waiter = mock.Mock()
79+
80+
# No ValueError is raised from SSLProtocol with 'server_hostname'.
81+
ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter,
82+
server_hostname='example.org')
83+
self.addCleanup(ssl_proto._app_transport.close)
84+
85+
@support.subTests("server_hostname", [None, ''])
86+
def test_check_hostname_requires_server_hostname(self, server_hostname):
87+
# A caller-supplied context asking for hostname checking used to be
88+
# taken through wrap_bio() with no name to check against, verifying
89+
# the certificate chain but never the peer's identity.
90+
# loop.start_tls() defaults server_hostname to None, and
91+
# loop.create_connection() turns server_hostname='' into None here,
92+
# so both reached that state.
93+
sslcontext = test_utils.simple_client_sslcontext(disable_verify=False)
94+
sslcontext.check_hostname = True
95+
app_proto = mock.Mock()
96+
waiter = mock.Mock()
97+
98+
# Supplying an empty server_hostname fails with check_hostname enabled.
99+
with self.assertRaisesRegex(
100+
ValueError,
101+
'check_hostname requires server_hostname'):
102+
sslproto.SSLProtocol(self.loop, app_proto, sslcontext,
103+
waiter,
104+
server_hostname=server_hostname)
105+
106+
# Disabling check_hostname allows for an empty or unset server_hostname.
107+
sslcontext.check_hostname = False
108+
109+
ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext, waiter)
110+
self.addCleanup(ssl_proto._app_transport.close)
111+
112+
ssl_proto = sslproto.SSLProtocol(self.loop, app_proto, sslcontext,
113+
waiter,
114+
server_hostname=server_hostname)
115+
self.addCleanup(ssl_proto._app_transport.close)
116+
73117
def test_eof_received_waiter(self):
74118
waiter = self.loop.create_future()
75119
ssl_proto = self.ssl_protocol(waiter=waiter)

‎Lib/test/test_ssl.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,34 @@ def do_ssl_object_handshake(sslobject, outgoing, max_retry=25):
412412
return data
413413

414414

415+
def connected_bio_pair(client_context, server_context, hostname, max_retry=5):
416+
"""Handshake a client and a server SSLObject against each other.
417+
418+
Everything happens in memory, so this needs no socket and no thread.
419+
Returns the two objects followed by their four BIOs, in the order
420+
client, server, c_in, c_out, s_in, s_out.
421+
"""
422+
c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO()
423+
s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO()
424+
client = client_context.wrap_bio(c_in, c_out, server_hostname=hostname)
425+
server = server_context.wrap_bio(s_in, s_out, server_side=True)
426+
427+
# Loop on the handshake for a bit to get it settled
428+
for _ in range(max_retry):
429+
with contextlib.suppress(ssl.SSLWantReadError):
430+
client.do_handshake()
431+
if c_out.pending:
432+
s_in.write(c_out.read())
433+
with contextlib.suppress(ssl.SSLWantReadError):
434+
server.do_handshake()
435+
if s_out.pending:
436+
c_in.write(s_out.read())
437+
# Now the handshakes should be complete (don't raise WantReadError)
438+
client.do_handshake()
439+
server.do_handshake()
440+
return client, server, c_in, c_out, s_in, s_out
441+
442+
415443
class BasicSocketTests(unittest.TestCase):
416444

417445
def test_constants(self):
@@ -1747,6 +1775,7 @@ def test__create_stdlib_context_check_hostname(self):
17471775
def test_delete_sslobject_attributes(self):
17481776
# None of the attributes of _ssl._SSLSocket can be deleted.
17491777
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
1778+
ctx.check_hostname = False
17501779
sslobj = ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO())._sslobj
17511780
for name in 'context', 'owner', 'session', 'session_reused':
17521781
with self.subTest(name=name):
@@ -1941,6 +1970,10 @@ def test_subclass(self):
19411970

19421971
def test_bad_server_hostname(self):
19431972
ctx = ssl.create_default_context()
1973+
# Omitting the name entirely is bad too: this context checks it.
1974+
with self.assertRaises(ValueError):
1975+
ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
1976+
server_hostname=None)
19441977
with self.assertRaises(ValueError):
19451978
ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
19461979
server_hostname="")
@@ -2025,6 +2058,64 @@ def test_private_init(self):
20252058
with self.assertRaisesRegex(TypeError, "public constructor"):
20262059
ssl.SSLObject(bio, bio)
20272060

2061+
def test_check_hostname_requires_server_hostname(self):
2062+
# wrap_bio() used to accept a context asking for hostname checking
2063+
# without a name to check against, and then verify the certificate
2064+
# chain but never the peer's identity, with check_hostname still
2065+
# reporting True and nothing reporting the check had been skipped.
2066+
# It must refuse that call, as wrap_socket() already did.
2067+
client_context, _, hostname = testing_context()
2068+
self.assertTrue(client_context.check_hostname)
2069+
2070+
for server_hostname in (None, ""):
2071+
with self.subTest(server_hostname=server_hostname):
2072+
with self.assertRaisesRegex(
2073+
ValueError,
2074+
"check_hostname requires server_hostname"):
2075+
client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
2076+
server_hostname=server_hostname)
2077+
# The sibling constructor refuses the very same call.
2078+
with socket.socket() as sock:
2079+
with self.assertRaisesRegex(
2080+
ValueError,
2081+
"check_hostname requires server_hostname"):
2082+
client_context.wrap_socket(
2083+
sock, server_hostname=server_hostname)
2084+
2085+
# A name was all that was missing.
2086+
client_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
2087+
server_hostname=hostname)
2088+
2089+
# Asking for no hostname check remains a way to say so explicitly.
2090+
context = make_test_context()
2091+
self.assertFalse(context.check_hostname)
2092+
context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO())
2093+
2094+
def test_server_side_bad_params(self):
2095+
# A server neither sends a hostname nor resumes a client's session,
2096+
# so wrap_bio() rejects both in server mode like wrap_socket()
2097+
client_context, server_context, hostname = testing_context()
2098+
2099+
with self.assertRaisesRegex(
2100+
ValueError,
2101+
"server_hostname can only be specified in client mode"):
2102+
server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
2103+
server_side=True,
2104+
server_hostname=hostname)
2105+
2106+
client, server, *_ = connected_bio_pair(
2107+
client_context, server_context, hostname)
2108+
session = client.session
2109+
self.assertIsNotNone(session)
2110+
with self.assertRaisesRegex(
2111+
ValueError, "session can only be specified in client mode"):
2112+
server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
2113+
server_side=True, session=session)
2114+
2115+
# Neither argument is what a server passes, so this still works.
2116+
server_context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(),
2117+
server_side=True)
2118+
20282119
def test_unwrap(self):
20292120
client_ctx, server_ctx, hostname = testing_context()
20302121
c_in = ssl.MemoryBIO()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
:meth:`ssl.SSLContext.wrap_bio` now validates its *server_side*,
2+
*server_hostname* and *session* arguments similar to
3+
:meth:`ssl.SSLContext.wrap_socket`.
4+
5+
In particular, a context with :attr:`~ssl.SSLContext.check_hostname` enabled
6+
and no *server_hostname* passed to :meth:`!wrap_bio` now raises :exc:`ValueError`
7+
instead of completing a handshake that verified the certificate chain
8+
without verifying the peer's identity, with no indication that the
9+
check had been skipped.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:mod:`asyncio`: :meth:`loop.start_tls() <asyncio.loop.start_tls>` and
2+
:meth:`loop.create_connection() <asyncio.loop.create_connection>` now
3+
validate the *server_hostname* argument if an :class:`ssl.SSLContext` is
4+
passed with *check_hostname* set to ``True``.

0 commit comments

Comments
 (0)