From b1d982f1ea357866226b76c8a56c59b469de9a59 Mon Sep 17 00:00:00 2001 From: Kadir Can Ozden <101993364+bysiber@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:43:33 +0300 Subject: [PATCH 1/2] Mask reserved bit when parsing GoAway and WindowUpdate frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoAwayFrame.serialize_body already masks last_stream_id with & 0x7FFFFFFF, but parse_body reads the raw 32-bit value without stripping the reserved top bit. If a peer happens to set that bit, last_stream_id would be read as a value >= 2^31 instead of the actual stream ID. Similarly, WindowUpdateFrame.serialize_body masks window_increment with & 0x7FFFFFFF, but parse_body doesn't. If the reserved bit is set, the unmasked value exceeds 2^31-1 and the frame is rejected with InvalidDataError — even though RFC 9113 Section 6.9 says the reserved bit "MUST be ignored when receiving." The rest of the codebase already follows this pattern: - Frame.parse_frame_header masks stream_id & 0x7FFFFFFF - Priority.parse_priority_data masks depends_on & 0x7FFFFFFF Add the same mask to GoAwayFrame.parse_body and WindowUpdateFrame.parse_body for consistency. --- src/hyperframe/frame.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hyperframe/frame.py b/src/hyperframe/frame.py index b5ca2e5..9efc927 100644 --- a/src/hyperframe/frame.py +++ b/src/hyperframe/frame.py @@ -639,6 +639,7 @@ def parse_body(self, data: memoryview) -> None: msg = "Invalid GOAWAY body." raise InvalidFrameError(msg) from err + self.last_stream_id = self.last_stream_id & 0x7FFFFFFF self.body_len = len(data) if len(data) > 8: @@ -690,6 +691,8 @@ def parse_body(self, data: memoryview) -> None: msg = "Invalid WINDOW_UPDATE body" raise InvalidFrameError(msg) from err + self.window_increment = self.window_increment & 0x7FFFFFFF + if not 1 <= self.window_increment <= 2**31-1: msg = "WINDOW_UPDATE increment must be between 1 to 2^31-1" raise InvalidDataError(msg) From 8783dcb3c8048cdad91d8fb10a6276a310ebed00 Mon Sep 17 00:00:00 2001 From: Thomas Kriechbaumer Date: Fri, 3 Apr 2026 12:39:01 +0200 Subject: [PATCH 2/2] reserved bit parsing and serializing: add comments, tests, and changelog --- CHANGELOG.rst | 3 +++ src/hyperframe/frame.py | 16 +++++++++------ tests/test_frames.py | 45 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5834785..ccdb5ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ dev **API Changes (Backward Compatible)** - Setting Identifier are now correctly serialized as 16-bit values, instead of 8-bit. +- GoAwayFrame and WindowUpdateFrame now correctly mask off the reserved bit during + parsing and serialization of stream IDs and window increments, as per RFC 9113, + Sections 6.8 and 6.9. **API Changes (Backward Incompatible)** diff --git a/src/hyperframe/frame.py b/src/hyperframe/frame.py index 9efc927..0f84f1c 100644 --- a/src/hyperframe/frame.py +++ b/src/hyperframe/frame.py @@ -132,7 +132,7 @@ def parse_frame_header(header: memoryview, strict: bool = False) -> tuple[Frame, length = (fields[0] << 8) + fields[1] typ_e = fields[2] flags = fields[3] - stream_id = fields[4] & 0x7FFFFFFF + stream_id = fields[4] & 0x7FFFFFFF # mask off the reserved bit, RFC 9113, Section 4.1 try: frame = FRAMES[typ_e](stream_id) @@ -172,7 +172,7 @@ def serialize(self) -> bytes: self.body_len & 0xFF, self.type, flags, - self.stream_id & 0x7FFFFFFF, # Stream ID is 32 bits. + self.stream_id & 0x7FFFFFFF, # mask off the reserved bit, RFC 9113, Section 4.1 ) return header + body @@ -271,7 +271,7 @@ def parse_priority_data(self, data: memoryview) -> int: raise InvalidFrameError(msg) from err self.exclusive = bool(self.depends_on >> 31) - self.depends_on &= 0x7FFFFFFF + self.depends_on &= 0x7FFFFFFF # mask off the exclusive bit, RFC 9113, Section 6.3 return 5 @@ -623,7 +623,7 @@ def _body_repr(self) -> str: def serialize_body(self) -> bytes: data = _STRUCT_LL.pack( - self.last_stream_id & 0x7FFFFFFF, + self.last_stream_id & 0x7FFFFFFF, # mask off the reserved bit, RFC 9113, Section 6.8 self.error_code, ) data += self.additional_data @@ -639,6 +639,7 @@ def parse_body(self, data: memoryview) -> None: msg = "Invalid GOAWAY body." raise InvalidFrameError(msg) from err + # mask off the reserved bit, RFC 9113, Section 6.8 self.last_stream_id = self.last_stream_id & 0x7FFFFFFF self.body_len = len(data) @@ -678,7 +679,9 @@ def _body_repr(self) -> str: return f"window_increment={self.window_increment}" def serialize_body(self) -> bytes: - return _STRUCT_L.pack(self.window_increment & 0x7FFFFFFF) + return _STRUCT_L.pack( + self.window_increment & 0x7FFFFFFF, # mask off the reserved bit, RFC 9113, Section 6.9 + ) def parse_body(self, data: memoryview) -> None: if len(data) > 4: @@ -691,6 +694,7 @@ def parse_body(self, data: memoryview) -> None: msg = "Invalid WINDOW_UPDATE body" raise InvalidFrameError(msg) from err + # mask off the reserved bit, RFC 9113, Section 6.9 self.window_increment = self.window_increment & 0x7FFFFFFF if not 1 <= self.window_increment <= 2**31-1: @@ -910,7 +914,7 @@ def serialize(self) -> bytes: self.body_len & 0xFF, self.type, flags, - self.stream_id & 0x7FFFFFFF, # Stream ID is 32 bits. + self.stream_id & 0x7FFFFFFF, # mask off the reserved bit, RFC 9113, Section 4.1 ) return header + self.body diff --git a/tests/test_frames.py b/tests/test_frames.py index 9b7de9d..b683354 100644 --- a/tests/test_frames.py +++ b/tests/test_frames.py @@ -670,6 +670,35 @@ def test_short_goaway_frame_errors(self): with pytest.raises(InvalidFrameError): decode_frame(s) + def test_goaway_frame_with_reserved_bit_set_parses_properly(self): + s = ( + b'\x00\x00\x0D\x07\x00\x00\x00\x00\x00' + # Frame header + b'\x80\x00\x00\x40' + # Last Stream ID with reserved bit set + b'\x00\x00\x00\x20' + # Error Code + b'hello' # Additional data + ) + f = decode_frame(s) + + assert isinstance(f, GoAwayFrame) + assert f.flags == set() + assert f.additional_data == b'hello' + assert f.body_len == 13 + assert f.last_stream_id == 64 + + def test_goaway_frame_with_reserved_bit_set_serializes_properly(self): + f = GoAwayFrame() + f.last_stream_id = 64 + f.error_code = 32 + f.additional_data = b'hello' + + s = f.serialize() + assert s == ( + b'\x00\x00\x0D\x07\x00\x00\x00\x00\x00' + # Frame header + b'\x00\x00\x00\x40' + # Last Stream ID + b'\x00\x00\x00\x20' + # Error Code + b'hello' # Additional data + ) + class TestWindowUpdateFrame: def test_repr(self): @@ -717,6 +746,22 @@ def test_short_windowupdate_frame_errors(self): with pytest.raises(InvalidDataError): decode_frame(WindowUpdateFrame(2**31).serialize()) + def test_window_update_frame_with_reserved_bit_set_parses_properly(self): + s = b'\x00\x00\x04\x08\x00\x00\x00\x00\x80\x00\x00\x02\x00' + f = decode_frame(s) + + assert isinstance(f, WindowUpdateFrame) + assert f.flags == set() + assert f.window_increment == 512 + assert f.body_len == 4 + + def test_window_update_frame_with_reserved_bit_set_serializes_properly(self): + f = WindowUpdateFrame(0) + f.window_increment = 512 + + s = f.serialize() + assert s == b'\x00\x00\x04\x08\x00\x00\x00\x00\x00\x00\x00\x02\x00' + class TestHeadersFrame: def test_repr(self):