From 1bea0a0b03b570606f8cbb2ccfa6c3b1471edc82 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 3 Oct 2026 07:11:56 +0800 Subject: [PATCH 1/3] Reject invalid control frames when building --- test/test_frame.py | 12 ++++++++++++ ws4py/framing.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/test/test_frame.py b/test/test_frame.py index b5b5dd3..ddac827 100644 --- a/test/test_frame.py +++ b/test/test_frame.py @@ -95,6 +95,18 @@ def test_frame_too_large(self): f.payload_length = 1 << 63 self.assertRaises(FrameTooLargeException, f.build) + def test_control_frame_payload_limit(self): + for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + self.assertEqual(len(Frame(opcode=opcode, body=b'*' * 125, fin=1).build()), 127) + self.assertRaises( + FrameTooLargeException, + Frame(opcode=opcode, body=b'*' * 126, fin=1).build, + ) + + def test_control_frames_cannot_be_fragmented(self): + for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + self.assertRaises(ValueError, Frame(opcode=opcode, body=b'', fin=0).build) + def test_passing_encoded_string(self): # once encoded the u'\xe9' character will be of length 2 f = Frame(opcode=OPCODE_TEXT, body=u'\xe9trange'.encode('utf-8'), fin=1) diff --git a/ws4py/framing.py b/ws4py/framing.py index 5046167..316b5a1 100644 --- a/ws4py/framing.py +++ b/ws4py/framing.py @@ -75,6 +75,12 @@ def build(self): if 0x3 <= self.opcode <= 0x7 or 0xB <= self.opcode: raise ValueError('Opcode cannot be a reserved opcode') + if self.opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + if self.fin != 1: + raise ValueError('Control frames cannot be fragmented') + if self.payload_length > 125: + raise FrameTooLargeException() + ## +-+-+-+-+-------+ ## |F|R|R|R| opcode| ## |I|S|S|S| (4) | From 909b8ab87546aa82397912aac949f17830e8877d Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sun, 4 Oct 2026 19:40:24 +0800 Subject: [PATCH 2/3] Cover control-frame output bounds and malformed peer input --- test/test_frame.py | 28 +++++++++++++++++++++++++++- test/test_stream.py | 5 +++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/test/test_frame.py b/test/test_frame.py index ddac827..7f0fda0 100644 --- a/test/test_frame.py +++ b/test/test_frame.py @@ -95,6 +95,31 @@ def test_frame_too_large(self): f.payload_length = 1 << 63 self.assertRaises(FrameTooLargeException, f.build) + def test_control_frames_cannot_be_fragmented(self): + for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + f = Frame(opcode=opcode, body=b'control', fin=0) + self.assertRaises(ValueError, f.build) + + def test_control_frames_cannot_exceed_125_bytes(self): + for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + for mask in (None, b'1234'): + f = Frame(opcode=opcode, body=b'*' * 126, + masking_key=mask, fin=1) + self.assertRaises(FrameTooLargeException, f.build) + + def test_control_frames_accept_empty_and_125_byte_payloads(self): + for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + for mask in (None, b'1234'): + for body in (b'', b'*' * 125): + data = Frame(opcode=opcode, body=body, + masking_key=mask, fin=1).build() + parsed = Frame() + parsed.parser.send(data) + self.assertEqual(parsed.opcode, opcode) + self.assertEqual(parsed.payload_length, len(body)) + decoded = parsed.unmask(parsed.body) if mask else parsed.body + self.assertEqual(decoded, body) + def test_control_frame_payload_limit(self): for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): self.assertEqual(len(Frame(opcode=opcode, body=b'*' * 125, fin=1).build()), 127) @@ -199,7 +224,8 @@ def test_fragmented_control_frame_is_invalid(self): self.assertRaises(ProtocolException, f.parser.send, b'0x9') def test_fragmented_control_frame_is_too_large(self): - bytes = Frame(opcode=OPCODE_PING, body=b'*'*65536, fin=1).build() + # Construct malformed peer input without the validating sender. + bytes = b'\x89\x7f' + pack('!Q', 65536) + b'*' * 65536 f = Frame() self.assertRaises(FrameTooLargeException, f.parser.send, bytes) diff --git a/test/test_stream.py b/test/test_stream.py index 28c79d9..efd3d15 100644 --- a/test/test_stream.py +++ b/test/test_stream.py @@ -128,8 +128,9 @@ def test_invalid_encoded_bytes_on_continuation(self): def test_too_large_close_message(self): payload = struct.pack("!H", 1000) + b'*' * 330 - f = Frame(opcode=OPCODE_CLOSE, body=payload, - fin=1, masking_key=os.urandom(4)).build() + # A zero masking key leaves the malformed peer payload unchanged. + # Do not ask the validating sender to build this oversized control frame. + f = b'\x88\xfe' + struct.pack('!H', len(payload)) + b'\x00' * 4 + payload s = Stream() self.assertEqual(len(s.errors), 0) self.assertEqual(s.closing, None) From e7bee6100b8424fb19127a26f1c9926be76ba022 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sun, 4 Oct 2026 19:44:34 +0800 Subject: [PATCH 3/3] Keep fragmented control-frame regressions distinct --- test/test_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_frame.py b/test/test_frame.py index 7f0fda0..80cf777 100644 --- a/test/test_frame.py +++ b/test/test_frame.py @@ -95,7 +95,7 @@ def test_frame_too_large(self): f.payload_length = 1 << 63 self.assertRaises(FrameTooLargeException, f.build) - def test_control_frames_cannot_be_fragmented(self): + def test_fragmented_control_frames_with_payload_are_rejected(self): for opcode in (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): f = Frame(opcode=opcode, body=b'control', fin=0) self.assertRaises(ValueError, f.build)