From 1412f5e4125b4313f815778a1acb4d3336efcd07 Mon Sep 17 00:00:00 2001 From: Onno Kortmann Date: Thu, 1 Apr 2021 16:15:47 +0200 Subject: [PATCH] websocket: Limit maximum uncompressed frame length to 8MiB This fixes a memory exhaustion DOS attack vector. References: GHSA-9p9m-jm8w-94p2 https://github.com/eventlet/eventlet/security/advisories/GHSA-9p9m-jm8w-94p2 --- eventlet/websocket.py | 34 +++++++++++++++++---- tests/websocket_new_test.py | 59 ++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/eventlet/websocket.py b/eventlet/websocket.py index 2222b4ba65..245993d55b 100644 --- a/eventlet/websocket.py +++ b/eventlet/websocket.py @@ -38,6 +38,7 @@ break ACCEPTABLE_CLIENT_ERRORS = set((errno.ECONNRESET, errno.EPIPE)) +DEFAULT_MAX_FRAME_LENGTH = 8 << 20 __all__ = ["WebSocketWSGI", "WebSocket"] PROTOCOL_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' @@ -75,14 +76,20 @@ def my_handler(ws): :class:`WebSocket`. To close the socket, simply return from the function. Note that the server will log the websocket request at the time of closure. + + An optional argument max_frame_length can be given, which will set the + maximum incoming *uncompressed* payload length of a frame. By default, this + is set to 8MiB. Note that excessive values here might create a DOS attack + vector. """ - def __init__(self, handler): + def __init__(self, handler, max_frame_length=DEFAULT_MAX_FRAME_LENGTH): self.handler = handler self.protocol_version = None self.support_legacy_versions = True self.supported_protocols = [] self.origin_checker = None + self.max_frame_length = max_frame_length @classmethod def configured(cls, @@ -324,7 +331,8 @@ def _handle_hybi_request(self, environ): sock.sendall(b'\r\n'.join(handshake_reply) + b'\r\n\r\n') return RFC6455WebSocket(sock, environ, self.protocol_version, protocol=negotiated_protocol, - extensions=parsed_extensions) + extensions=parsed_extensions, + max_frame_length=self.max_frame_length) def _extract_number(self, value): """ @@ -503,7 +511,8 @@ class ProtocolError(ValueError): class RFC6455WebSocket(WebSocket): - def __init__(self, sock, environ, version=13, protocol=None, client=False, extensions=None): + def __init__(self, sock, environ, version=13, protocol=None, client=False, extensions=None, + max_frame_length=DEFAULT_MAX_FRAME_LENGTH): super(RFC6455WebSocket, self).__init__(sock, environ, version) self.iterator = self._iter_frames() self.client = client @@ -512,6 +521,8 @@ def __init__(self, sock, environ, version=13, protocol=None, client=False, exten self._deflate_enc = None self._deflate_dec = None + self.max_frame_length = max_frame_length + self._remote_close_data = None class UTF8Decoder(object): def __init__(self): @@ -583,12 +594,13 @@ def _get_bytes(self, numbytes): return data class Message(object): - def __init__(self, opcode, decoder=None, decompressor=None): + def __init__(self, opcode, max_frame_length, decoder=None, decompressor=None): self.decoder = decoder self.data = [] self.finished = False self.opcode = opcode self.decompressor = decompressor + self.max_frame_length = max_frame_length def push(self, data, final=False): self.finished = final @@ -597,7 +609,12 @@ def push(self, data, final=False): def getvalue(self): data = b"".join(self.data) if not self.opcode & 8 and self.decompressor: - data = self.decompressor.decompress(data + b'\x00\x00\xff\xff') + data = self.decompressor.decompress(data + b"\x00\x00\xff\xff", self.max_frame_length) + if self.decompressor.unconsumed_tail: + raise FailedConnectionError( + 1009, + "Incoming compressed frame exceeds length limit of {} bytes.".format(self.max_frame_length)) + if self.decoder: data = self.decoder.decode(data, self.finished) return data @@ -611,6 +628,7 @@ def _apply_mask(data, mask, length=None, offset=0): def _handle_control_frame(self, opcode, data): if opcode == 8: # connection close + self._remote_close_data = data if not data: status = 1000 elif len(data) > 1: @@ -710,13 +728,17 @@ def _recv_frame(self, message=None): length = struct.unpack('!H', recv(2))[0] elif length == 127: length = struct.unpack('!Q', recv(8))[0] + + if length > self.max_frame_length: + raise FailedConnectionError(1009, "Incoming frame of {} bytes is above length limit of {} bytes.".format( + length, self.max_frame_length)) if masked: mask = struct.unpack('!BBBB', recv(4)) received = 0 if not message or opcode & 8: decoder = self.UTF8Decoder() if opcode == 1 else None decompressor = self._get_permessage_deflate_dec(rsv1) - message = self.Message(opcode, decoder=decoder, decompressor=decompressor) + message = self.Message(opcode, self.max_frame_length, decoder=decoder, decompressor=decompressor) if not length: message.push(b'', final=finished) else: diff --git a/tests/websocket_new_test.py b/tests/websocket_new_test.py index 5f98025ec7..cc857924fe 100644 --- a/tests/websocket_new_test.py +++ b/tests/websocket_new_test.py @@ -30,7 +30,12 @@ def handle(ws): else: ws.close() -wsapp = websocket.WebSocketWSGI(handle) + +# Set a lower limit of DEFAULT_MAX_FRAME_LENGTH for testing, as +# sending an 8MiB frame over the loopback interface can trigger a +# timeout. +TEST_MAX_FRAME_LENGTH = 50000 +wsapp = websocket.WebSocketWSGI(handle, max_frame_length=TEST_MAX_FRAME_LENGTH) class TestWebSocket(tests.wsgi_test._TestBase): @@ -534,3 +539,55 @@ def test_compressed_send_recv_both_no_context_13(self): ws.close() eventlet.sleep(0.01) + + def test_large_frame_size_compressed_13(self): + # Test fix for GHSA-9p9m-jm8w-94p2 + extensions_string = 'permessage-deflate' + extensions = {'permessage-deflate': { + 'client_no_context_takeover': False, + 'server_no_context_takeover': False}} + + sock = eventlet.connect(self.server_addr) + sock.sendall(six.b(self.connect % extensions_string)) + sock.recv(1024) + ws = websocket.RFC6455WebSocket(sock, {}, client=True, extensions=extensions) + + should_still_fit = b"x" * TEST_MAX_FRAME_LENGTH + one_too_much = should_still_fit + b"x" + + # send just fitting frame twice to make sure they are fine independently + ws.send(should_still_fit) + assert ws.wait() == should_still_fit + ws.send(should_still_fit) + assert ws.wait() == should_still_fit + ws.send(one_too_much) + + res = ws.wait() + assert res is None # socket closed + # TODO: The websocket currently sents compressed control frames, which contradicts RFC7692. + # Renable the following assert after that has been fixed. + # assert ws._remote_close_data == b"\x03\xf1Incoming compressed frame is above length limit." + eventlet.sleep(0.01) + + def test_large_frame_size_uncompressed_13(self): + # Test fix for GHSA-9p9m-jm8w-94p2 + sock = eventlet.connect(self.server_addr) + sock.sendall(six.b(self.connect)) + sock.recv(1024) + ws = websocket.RFC6455WebSocket(sock, {}, client=True) + + should_still_fit = b"x" * TEST_MAX_FRAME_LENGTH + one_too_much = should_still_fit + b"x" + + # send just fitting frame twice to make sure they are fine independently + ws.send(should_still_fit) + assert ws.wait() == should_still_fit + ws.send(should_still_fit) + assert ws.wait() == should_still_fit + ws.send(one_too_much) + + res = ws.wait() + assert res is None # socket closed + # close code should be available now + assert ws._remote_close_data == b"\x03\xf1Incoming frame of 50001 bytes is above length limit of 50000 bytes." + eventlet.sleep(0.01)