diff --git a/Lib/asyncio/sslproto.py b/Lib/asyncio/sslproto.py index 74c5f0d5ca0609..84e82fc69cc5fd 100644 --- a/Lib/asyncio/sslproto.py +++ b/Lib/asyncio/sslproto.py @@ -588,6 +588,12 @@ def _on_handshake_complete(self, handshake_exc): msg = 'SSL handshake failed on verifying the certificate' else: msg = 'SSL handshake failed' + # gh-98078: When the handshake fails, OpenSSL leaves the fatal + # TLS alert (for example "bad certificate" or "protocol + # version") in the outgoing BIO. Send it to the peer before + # closing the transport so that it knows why the handshake + # failed. + self._process_outgoing() self._fatal_error(exc, msg) self._wakeup_waiter(exc) return @@ -652,6 +658,10 @@ def _do_shutdown(self): except SSLAgainErrors: self._process_outgoing() except ssl.SSLError as exc: + # gh-98078: send what OpenSSL left in the outgoing BIO, e.g. + # the close_notify alert, to the peer before closing (see + # _on_handshake_complete()). + self._process_outgoing() self._on_shutdown_complete(exc) else: self._process_outgoing() @@ -743,6 +753,11 @@ def _do_read(self): else: self._process_outgoing() self._control_ssl_reading() + except ssl.SSLError as ex: + # gh-98078: send the fatal TLS alert left in the outgoing + # BIO to the peer (see _on_handshake_complete()). + self._process_outgoing() + self._fatal_error(ex, 'Fatal error on SSL protocol') except Exception as ex: self._fatal_error(ex, 'Fatal error on SSL protocol') diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index 1b1437b51bab0a..656cdf570fad7b 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -753,14 +753,16 @@ def test_create_connection_ssl_failed_certificate(self): sslctx = test_utils.simple_server_sslcontext() client_sslctx = test_utils.simple_client_sslcontext( disable_verify=False) + server_err = None def server(sock): + nonlocal server_err try: sock.start_tls( sslctx, server_side=True) - except ssl.SSLError: - pass + except ssl.SSLError as exc: + server_err = exc except OSError: pass finally: @@ -780,13 +782,71 @@ async def client(addr): with self.assertRaises(ssl.SSLCertVerificationError): self.loop.run_until_complete(client(srv.addr)) + # gh-98078: the client must send a fatal TLS alert to the + # server instead of just closing the connection, so that the + # server knows why the handshake failed. + self.assertIsInstance(server_err, ssl.SSLError) + self.assertIn('ALERT_UNKNOWN_CA', server_err.reason or '') + + def test_create_server_ssl_failed_handshake_sends_alert(self): + # gh-98078: when the handshake fails, the server must send the + # fatal TLS alert generated by OpenSSL to the client before + # closing the connection, so that the client knows why the + # handshake failed (here: no TLS version in common). + if not ssl.HAS_TLSv1_3 or not ssl.HAS_TLSv1_2: + self.skipTest('needs TLSv1.2 and TLSv1.3 support') + + self.loop.set_exception_handler(lambda loop, ctx: None) + + server_context = test_utils.simple_server_sslcontext() + server_context.minimum_version = ssl.TLSVersion.TLSv1_3 + client_context = test_utils.simple_client_sslcontext() + client_context.maximum_version = ssl.TLSVersion.TLSv1_2 + + client_done = self.loop.create_future() + client_err = None + + def client(sock, addr): + nonlocal client_err + try: + sock.settimeout(self.TIMEOUT) + sock.connect(addr) + try: + sock.start_tls(client_context) + except OSError as exc: + client_err = exc + finally: + sock.close() + finally: + self.loop.call_soon_threadsafe( + client_done.set_result, None) + + async def run_main(): + server = await self.loop.create_server( + asyncio.Protocol, '127.0.0.1', 0, ssl=server_context) + addr = server.sockets[0].getsockname() + try: + with self.tcp_client(lambda sock: client(sock, addr), + timeout=self.TIMEOUT): + await asyncio.wait_for(client_done, self.TIMEOUT) + finally: + server.close() + await server.wait_closed() + + self.loop.run_until_complete(run_main()) + + self.assertIsInstance(client_err, ssl.SSLError) + self.assertIn('ALERT_PROTOCOL_VERSION', client_err.reason or '') + def test_start_tls_client_corrupted_ssl(self): self.loop.set_exception_handler(lambda loop, ctx: None) sslctx = test_utils.simple_server_sslcontext() client_sslctx = test_utils.simple_client_sslcontext() + server_err = None def server(sock): + nonlocal server_err orig_sock = sock.dup() try: sock.start_tls( @@ -795,8 +855,11 @@ def server(sock): sock.sendall(b'A\n') sock.recv_all(1) orig_sock.send(b'please corrupt the SSL connection') - except ssl.SSLError: - pass + # gh-98078: receive the fatal TLS alert sent by the + # client before it closed the connection + sock.recv(16) + except ssl.SSLError as exc: + server_err = exc finally: orig_sock.close() sock.close() @@ -822,6 +885,71 @@ async def client(addr): res = self.loop.run_until_complete(client(srv.addr)) self.assertEqual(res, 'OK') + # gh-98078: the client must send a fatal TLS alert to the + # server instead of just closing the connection, so that the + # server knows why the connection was dropped. + self.assertIsInstance(server_err, ssl.SSLError) + self.assertIn('ALERT_', server_err.reason or '') + + def test_shutdown_corrupted_ssl_sends_close_notify(self): + # gh-98078: when the TLS shutdown fails (here: on a corrupted + # record that was buffered while the application had reading + # paused), the close_notify alert that OpenSSL already + # generated must be sent to the peer before the transport is + # closed, so that the peer sees a clean TLS EOF instead of a + # connection reset. + self.loop.set_exception_handler(lambda loop, ctx: None) + + sslctx = test_utils.simple_server_sslcontext() + client_sslctx = test_utils.simple_client_sslcontext() + server_err = None + + def server(sock): + nonlocal server_err + orig_sock = sock.dup() + try: + sock.start_tls( + sslctx, + server_side=True) + sock.sendall(b'A\n') + sock.recv_all(1) + orig_sock.send(b'please corrupt the SSL connection') + # the client now closes the connection; although its + # TLS shutdown fails on the corrupted record, it must + # still send close_notify, completing our unwrap() + sock.unwrap() + except ssl.SSLError as exc: + server_err = exc + finally: + orig_sock.close() + sock.close() + + async def client(addr): + reader, writer = await asyncio.open_connection( + *addr, + ssl=client_sslctx, + server_hostname='') + # drain the post-handshake data (e.g. TLS session tickets) + # so that only the corrupted record can be buffered next + self.assertEqual(await reader.readline(), b'A\n') + # keep the corrupted record buffered in the incoming BIO + writer.transport.pause_reading() + writer.write(b'B') + await writer.drain() + # wait for the corrupted record to arrive in the read buffer + async with asyncio.timeout(support.SHORT_TIMEOUT): + while not writer.transport.get_read_buffer_size(): + await asyncio.sleep(0) + writer.close() + with self.assertRaises(ssl.SSLError): + await writer.wait_closed() + + with self.tcp_server(server, + max_clients=1, + backlog=1) as srv: + self.loop.run_until_complete(client(srv.addr)) + + self.assertIsNone(server_err) @unittest.skipIf(ssl is None, 'No ssl module') diff --git a/Misc/NEWS.d/next/Library/2026-07-12-10-00-00.gh-issue-98078.fA3lRt.rst b/Misc/NEWS.d/next/Library/2026-07-12-10-00-00.gh-issue-98078.fA3lRt.rst new file mode 100644 index 00000000000000..de710283c17b8e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-12-10-00-00.gh-issue-98078.fA3lRt.rst @@ -0,0 +1,6 @@ +Fix :mod:`asyncio` SSL transports not sending the fatal TLS alert to the +peer when the TLS handshake fails or when receiving corrupted data, and +not sending the ``close_notify`` alert to the peer when the TLS shutdown +fails. The peer can now tell why the connection was dropped (for example, +certificate verification failure or no TLS version in common) instead of +seeing the connection abruptly closed.