Skip to content

Commit 8a5e24a

Browse files
authored
gh-160499: Pause asyncio TLS reads when SSL replies pile up (#160500)
1 parent 43a4511 commit 8a5e24a

3 files changed

Lines changed: 61 additions & 1 deletion

File tree

‎Lib/asyncio/sslproto.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ def __init__(self, loop, app_protocol, sslcontext, waiter,
308308
# App data write buffering
309309
self._write_backlog = collections.deque()
310310
self._write_buffer_size = 0
311+
# How much of self._outgoing was produced by _do_write(): encrypted
312+
# app data, plus anything else OpenSSL flushed along with it
313+
self._outgoing_app_size = 0
311314

312315
self._waiter = waiter
313316
self._loop = loop
@@ -720,6 +723,7 @@ def _write_appdata(self, list_of_data):
720723
self._fatal_error(ex, 'Fatal error on SSL protocol')
721724

722725
def _do_write(self):
726+
pending = self._outgoing.pending
723727
try:
724728
while self._write_backlog:
725729
data = self._write_backlog[0]
@@ -733,11 +737,13 @@ def _do_write(self):
733737
self._write_buffer_size -= data_len
734738
except SSLAgainErrors:
735739
pass
740+
self._outgoing_app_size += self._outgoing.pending - pending
736741
self._process_outgoing()
737742

738743
def _process_outgoing(self):
739744
if not self._ssl_writing_paused:
740745
data = self._outgoing.read()
746+
self._outgoing_app_size = 0
741747
if len(data):
742748
self._transport.write(data)
743749
self._control_app_writing()
@@ -904,7 +910,15 @@ def resume():
904910
# Flow control for reads from SSL socket
905911

906912
def _control_ssl_reading(self):
907-
size = self._get_read_buffer_size()
913+
# Also count the records OpenSSL writes on its own while reading
914+
# (alerts, KeyUpdate replies, ...) which the peer does not read:
915+
# the app is not involved in producing them, so pausing it does
916+
# not bound them; only pausing reads from the peer does. Whatever
917+
# was produced while writing app data (_outgoing_app_size) is left
918+
# out so that two peers which both write before they read cannot
919+
# deadlock.
920+
size = (self._get_read_buffer_size() +
921+
self._outgoing.pending - self._outgoing_app_size)
908922
if size >= self._incoming_high_water and not self._ssl_reading_paused:
909923
self._ssl_reading_paused = True
910924
self._transport.pause_reading()
@@ -937,6 +951,7 @@ def resume_writing(self):
937951
assert self._ssl_writing_paused
938952
self._ssl_writing_paused = False
939953
self._process_outgoing()
954+
self._control_ssl_reading()
940955

941956
def _fatal_error(self, exc, message='Fatal error on transport'):
942957
if self._transport:

‎Lib/test/test_asyncio/test_sslproto.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,46 @@ def test_write_after_closing(self):
289289
# should not raise
290290
self.assertIsNone(transp.write(b'data'))
291291

292+
def test_pause_reading_if_ssl_replies_are_not_read(self):
293+
# Output which OpenSSL produces on its own while reading (alerts,
294+
# KeyUpdate, ...) must not pile up if the peer does not read it.
295+
ssl_proto = self.ssl_protocol()
296+
transport = self.connection_made(ssl_proto, do_handshake=mock.Mock())
297+
reply = b'x' * (ssl_proto._incoming_high_water // 2)
298+
299+
def read(*args):
300+
ssl_proto._outgoing.write(reply)
301+
raise ssl.SSLWantReadError
302+
ssl_proto._sslobj.read.side_effect = read
303+
304+
ssl_proto.pause_writing()
305+
ssl_proto.buffer_updated(0)
306+
transport.pause_reading.assert_not_called()
307+
ssl_proto.buffer_updated(0)
308+
transport.pause_reading.assert_called_once_with()
309+
transport.resume_reading.assert_not_called()
310+
transport.write.assert_not_called()
311+
312+
ssl_proto.resume_writing()
313+
transport.write.assert_called_once_with(reply * 2)
314+
transport.resume_reading.assert_called_once_with()
315+
316+
def test_no_pause_reading_if_app_data_is_not_read(self):
317+
# Peers which both write a lot before they read must not deadlock.
318+
ssl_proto = self.ssl_protocol()
319+
transport = self.connection_made(ssl_proto, do_handshake=mock.Mock())
320+
ssl_proto._sslobj.write.side_effect = ssl_proto._outgoing.write
321+
data = b'x' * ssl_proto._incoming_high_water
322+
323+
ssl_proto.pause_writing()
324+
ssl_proto._app_transport.write(data)
325+
ssl_proto.buffer_updated(0)
326+
transport.pause_reading.assert_not_called()
327+
328+
ssl_proto.resume_writing()
329+
transport.write.assert_called_once_with(data)
330+
transport.resume_reading.assert_not_called()
331+
292332

293333
##############################################################################
294334
# Start TLS Tests
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix unbounded memory growth in :mod:`asyncio` TLS connections when a peer
2+
stops reading but keeps sending records that make OpenSSL generate its own
3+
replies (such as TLS 1.2 renegotiation alerts). Reading from the peer is now
4+
paused when this output accumulates, instead of only when application data
5+
does.

0 commit comments

Comments
 (0)