From a47a63e008d02ff7c3a0b28452919df120cdacea Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 3 Aug 2026 00:42:57 +0100 Subject: [PATCH 1/2] Fix websocket regression (#13302) --- CHANGES/13274.bugfix.rst | 1 + aiohttp/_websocket/reader_py.py | 34 ++++++++++++++++++++------------- tests/test_websocket_parser.py | 25 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 CHANGES/13274.bugfix.rst diff --git a/CHANGES/13274.bugfix.rst b/CHANGES/13274.bugfix.rst new file mode 100644 index 00000000000..d1d248bee77 --- /dev/null +++ b/CHANGES/13274.bugfix.rst @@ -0,0 +1 @@ +Fixed the WebSocket reader rejecting a compressed data frame with close code 1002 when a control frame arrived before the first data frame (regression in 3.14.2) -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/_websocket/reader_py.py b/aiohttp/_websocket/reader_py.py index 1a81b09ebbd..6e2b99ff491 100644 --- a/aiohttp/_websocket/reader_py.py +++ b/aiohttp/_websocket/reader_py.py @@ -402,22 +402,30 @@ def _feed_data(self, data: bytes) -> None: "Control frame payload cannot be larger than 125 bytes", ) - # Set compress status if last package is FIN - # OR set compress status if this is first fragment - # Raise error if not first fragment with rsv1 = 0x1 - if self._frame_fin or self._compressed == COMPRESSED_NOT_SET: - self._compressed = COMPRESSED_TRUE if rsv1 else COMPRESSED_FALSE - elif rsv1: - raise WebSocketError( - WSCloseCode.PROTOCOL_ERROR, - "Received frame with non-zero reserved bits", - ) - # Control frames (opcode > 0x7) may be interleaved between the - # fragments of a data message. + # fragments of a data message and never carry the per-message + # compressed bit, so they must not touch the compression state. # https://datatracker.ietf.org/doc/html/rfc6455#section-5.4 - if opcode <= 0x7: + # https://datatracker.ietf.org/doc/html/rfc7692#section-6.1 + if opcode > 0x7: + if rsv1: + raise WebSocketError( + WSCloseCode.PROTOCOL_ERROR, + "Received frame with non-zero reserved bits", + ) + else: + # Set compress status if last package is FIN + # OR set compress status if this is first fragment + # Raise error if not first fragment with rsv1 = 0x1 + if self._frame_fin or self._compressed == COMPRESSED_NOT_SET: + self._compressed = COMPRESSED_TRUE if rsv1 else COMPRESSED_FALSE + elif rsv1: + raise WebSocketError( + WSCloseCode.PROTOCOL_ERROR, + "Received frame with non-zero reserved bits", + ) self._frame_fin = bool(fin) + self._frame_opcode = opcode self._has_mask = bool(has_mask) self._payload_len_flag = length diff --git a/tests/test_websocket_parser.py b/tests/test_websocket_parser.py index e9dc7def9eb..8045cf7956a 100644 --- a/tests/test_websocket_parser.py +++ b/tests/test_websocket_parser.py @@ -634,6 +634,31 @@ def test_compressed_continuation_with_ping( assert out._buffer[1] == WSMessageBinary(data=message, size=len(message), extra="") +def test_compressed_frame_after_control_frame( + out: WebSocketDataQueue, parser: PatchableWebSocketReader +) -> None: + # A control frame arriving before the first data frame must not + # latch the per-message compression state. + # https://github.com/aio-libs/aiohttp/issues/13274 + parser.feed_data(PACK_LEN1(0x80 | WSMsgType.PONG, 0)) + parser.feed_data(build_frame(b"hello", WSMsgType.TEXT, ZLibBackend=ZLibBackend)) + + assert out._buffer[0] == WSMessagePong(data=b"", size=0, extra="") + assert out._buffer[1] == WSMessageText(data="hello", size=5, extra="") + + +@pytest.mark.parametrize("opcode", (WSMsgType.PING, WSMsgType.PONG, WSMsgType.CLOSE)) +def test_control_frame_with_rsv1( + parser: PatchableWebSocketReader, opcode: WSMsgType +) -> None: + # Control frames never carry the per-message compressed bit. + # https://datatracker.ietf.org/doc/html/rfc7692#section-6.1 + with pytest.raises(WebSocketError) as ctx: + parser._feed_data(PACK_LEN1(0xC0 | opcode, 0)) + + assert ctx.value.code == WSCloseCode.PROTOCOL_ERROR + + def test_parse_compress_error_frame(parser: PatchableWebSocketReader) -> None: parser.parse_frame(struct.pack("!BB", 0b01000001, 0b00000001)) parser.parse_frame(b"1") From 537125adcbb10cb733461ba1b08d85d9199c48f0 Mon Sep 17 00:00:00 2001 From: arshsmith Date: Mon, 3 Aug 2026 05:33:24 +0530 Subject: [PATCH 2/2] strip leading separators in all content-disposition filename forms (#13206) --- CHANGES/13206.bugfix.rst | 1 + aiohttp/multipart.py | 6 ++--- tests/test_multipart_helpers.py | 46 ++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 CHANGES/13206.bugfix.rst diff --git a/CHANGES/13206.bugfix.rst b/CHANGES/13206.bugfix.rst new file mode 100644 index 00000000000..0febe080be3 --- /dev/null +++ b/CHANGES/13206.bugfix.rst @@ -0,0 +1 @@ +Fixed leading path separators in ``Content-Disposition`` not being stripped in some circumstances -- by :user:`arshsmith1`. diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 00ba6871c18..e767d077f9d 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -150,7 +150,7 @@ def unescape(text: str, *, chars: str = "".join(map(re.escape, CHAR))) -> str: continue try: - value = unquote(value, encoding, "strict") + value = unquote(value, encoding, "strict").lstrip("\\/") except (builtins.LookupError, UnicodeDecodeError): # The charset is attacker-controlled here; an unknown name # raises the builtin LookupError (the bare name is shadowed in @@ -214,14 +214,14 @@ def content_disposition_filename( encoding, _, value = value.split("'", 2) encoding = encoding or "utf-8" try: - return unquote(value, encoding, "strict") + return unquote(value, encoding, "strict").lstrip("\\/") except (builtins.LookupError, UnicodeDecodeError): # Both the charset name and the octets are attacker-controlled # here; an unknown encoding raises the builtin LookupError # (shadowed in this module by payload.LookupError) and # undecodable bytes raise UnicodeDecodeError. return None - return value + return value.lstrip("\\/") class MultipartResponseWrapper: diff --git a/tests/test_multipart_helpers.py b/tests/test_multipart_helpers.py index ffbb4c79f6a..bc6f43a957d 100644 --- a/tests/test_multipart_helpers.py +++ b/tests/test_multipart_helpers.py @@ -563,7 +563,23 @@ def test_attwithfn2231abspathdisguised(self) -> None: "attachment; filename*=UTF-8''%5cfoo.html" ) assert "attachment" == disptype - assert {"filename*": "\\foo.html"} == params + assert {"filename*": "foo.html"} == params + + def test_attwithfn2231abspath(self) -> None: + disptype, params = parse_content_disposition( + "attachment; filename*=UTF-8''%2Ffoo.html" + ) + assert "attachment" == disptype + assert {"filename*": "foo.html"} == params + + def test_attfncontabspath(self) -> None: + # The continuation parts are normalised once they are joined, so the + # separator survives parsing. + disptype, params = parse_content_disposition( + 'attachment; filename*0="/foo."; filename*1="html"' + ) + assert "attachment" == disptype + assert {"filename*0": "/foo.", "filename*1": "html"} == params def test_attfncont(self) -> None: disptype, params = parse_content_disposition( @@ -711,10 +727,28 @@ def test_filename_ext(self) -> None: params = {"filename*": "файл.html"} assert "файл.html" == content_disposition_filename(params) + def test_filename_ext_abspath(self) -> None: + _, params = parse_content_disposition( + 'form-data; name="f"; filename="/etc/evil"; filename*=UTF-8\'\'%2Fetc%2Fevil' + ) + assert "etc/evil" == content_disposition_filename(params) + def test_attfncont(self) -> None: params = {"filename*0": "foo.", "filename*1": "html"} assert "foo.html" == content_disposition_filename(params) + def test_attfncontabspath(self) -> None: + _, params = parse_content_disposition( + 'attachment; filename*0="/foo."; filename*1="html"' + ) + assert "foo.html" == content_disposition_filename(params) + + def test_attfncontinnerpath(self) -> None: + _, params = parse_content_disposition( + 'attachment; filename*0="dir"; filename*1="/foo.html"' + ) + assert "dir/foo.html" == content_disposition_filename(params) + def test_attfncontqs(self) -> None: params = {"filename*0": "foo", "filename*1": "bar.html"} assert "foobar.html" == content_disposition_filename(params) @@ -723,6 +757,16 @@ def test_attfncontenc(self) -> None: params = {"filename*0*": "UTF-8''foo-%c3%a4", "filename*1": ".html"} assert "foo-ä.html" == content_disposition_filename(params) + @pytest.mark.parametrize( + "params", + ( + {"filename*0*": "UTF-8''%2Ffoo-%c3%a4", "filename*1": ".html"}, + {"filename*0*": "UTF-8''%5cfoo-%c3%a4", "filename*1": ".html"}, + ), + ) + def test_attfncontencabspath(self, params: dict[str, str]) -> None: + assert "foo-ä.html" == content_disposition_filename(params) + @pytest.mark.parametrize( "params", (