From 8cdc09196c409d840bc1e96f8cb791cbc7e361f5 Mon Sep 17 00:00:00 2001 From: Ofek Danny <63648262+OfekDanny@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:28 +0300 Subject: [PATCH 1/4] fix: ignore body in CONNECT 2xx proxy responses per RFC-9110 --- aiohttp/connector.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 547f9719d39..716b869b7e3 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -1532,9 +1532,13 @@ async def _create_proxy_connection( # read_until_eof=True will ensure the connection isn't closed # once the response is received and processed allowing # START_TLS to work on the connection below. + # skip_payload=True per RFC-9110 §9.3.6: a client MUST ignore + # any Content-Length or Transfer-Encoding header fields received + # in a successful response to CONNECT. protocol.set_response_params( read_until_eof=True, timeout_ceil_threshold=self._timeout_ceil_threshold, + skip_payload=True, ) resp = await proxy_resp.start(conn) except BaseException: From 726807600018a8d1e6350f6ae0539f1888c875af Mon Sep 17 00:00:00 2001 From: Ofek Danny <63648262+OfekDanny@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:37 +0300 Subject: [PATCH 2/4] =?UTF-8?q?test:=20add=20regression=20test=20for=20CON?= =?UTF-8?q?NECT=20response=20body=20skip=20(RFC-9110=20=C2=A79.3.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_proxy.py | 99 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 147b5998b8e..da47423e60f 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -1167,3 +1167,102 @@ async def test_https_auth( # type: ignore[misc] proxy_resp.close() await req._close() await connector.close() + + +@mock.patch("aiohttp.connector.ClientRequestBase") +@mock.patch( + "aiohttp.connector.aiohappyeyeballs.start_connection", + autospec=True, + spec_set=True, +) +async def test_https_connect_skip_payload_on_200( # type: ignore[misc] + start_connection: mock.Mock, + ClientRequestMock: mock.Mock, + make_client_request: _RequestMaker, +) -> None: + """Regression test for https://github.com/aio-libs/aiohttp/issues/8472. + + Per RFC-9110 §9.3.6 a client MUST ignore any Content-Length or + Transfer-Encoding header fields in a successful response to CONNECT. + Verify that set_response_params is called with skip_payload=True so the + HTTP parser does not try to read a body from the 200 tunnel response. + """ + event_loop = asyncio.get_running_loop() + proxy_req = ClientRequestBase( + "GET", + URL("http://proxy.example.com"), + auth=None, + loop=event_loop, + ssl=True, + headers=CIMultiDict({}), + ) + ClientRequestMock.return_value = proxy_req + + url = URL("http://proxy.example.com") + proxy_resp = ClientResponse( + "get", + url, + writer=None, + continue100=None, + timer=TimerNoop(), + traces=[], + loop=event_loop, + session=mock.Mock(), + request_headers=CIMultiDict[str](), + original_url=url, + ) + with mock.patch.object(proxy_req, "_send", autospec=True, return_value=proxy_resp): + with mock.patch.object(proxy_resp, "start", autospec=True) as m: + m.return_value.status = 200 + + connector = aiohttp.TCPConnector() + r = { + "hostname": "hostname", + "host": "127.0.0.1", + "port": 80, + "family": socket.AF_INET, + "proto": 0, + "flags": 0, + } + with mock.patch.object( + connector, "_resolve_host", autospec=True, return_value=[r] + ): + tr, proto = mock.Mock(), mock.Mock() + with mock.patch.object( + event_loop, + "create_connection", + autospec=True, + return_value=(tr, proto), + ): + with mock.patch.object( + event_loop, + "start_tls", + autospec=True, + return_value=mock.Mock(), + ): + req = make_client_request( + "GET", + URL("https://www.python.org"), + proxy=URL("http://proxy.example.com"), + loop=event_loop, + ) + # Capture the set_response_params call on the tunnel + # protocol to verify skip_payload=True is passed. + with mock.patch( + "aiohttp.connector.ResponseHandler.set_response_params", + autospec=True, + ) as set_params_mock: + await connector._create_connection( + req, [], aiohttp.ClientTimeout() + ) + + set_params_mock.assert_called_once_with( + mock.ANY, # self + read_until_eof=True, + timeout_ceil_threshold=mock.ANY, + skip_payload=True, + ) + + proxy_resp.close() + await req._close() + await connector.close() From 86ea5fb59d5134c9c9bc9f94bf5c892885e68250 Mon Sep 17 00:00:00 2001 From: Ofek Danny Date: Mon, 20 Apr 2026 15:30:21 +0300 Subject: [PATCH 3/4] fix: use proto.set_response_params assertion in proxy test Co-Authored-By: Claude Sonnet 4.6 --- tests/test_proxy.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index da47423e60f..9ae073a6c02 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -1246,18 +1246,14 @@ async def test_https_connect_skip_payload_on_200( # type: ignore[misc] proxy=URL("http://proxy.example.com"), loop=event_loop, ) - # Capture the set_response_params call on the tunnel - # protocol to verify skip_payload=True is passed. - with mock.patch( - "aiohttp.connector.ResponseHandler.set_response_params", - autospec=True, - ) as set_params_mock: - await connector._create_connection( - req, [], aiohttp.ClientTimeout() - ) + await connector._create_connection( + req, [], aiohttp.ClientTimeout() + ) - set_params_mock.assert_called_once_with( - mock.ANY, # self + # proto is the mock protocol returned by create_connection. + # The connector calls protocol.set_response_params(...) on it + # directly, so we assert on proto's auto-mock attribute. + proto.set_response_params.assert_called_once_with( read_until_eof=True, timeout_ceil_threshold=mock.ANY, skip_payload=True, From 9e69861423ddf68a791cecb3ff1c3a69713a6749 Mon Sep 17 00:00:00 2001 From: Ofek Danny Date: Tue, 21 Apr 2026 11:39:12 +0300 Subject: [PATCH 4/4] test: use real ResponseHandler to assert _skip_payload behavioral state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: instead of asserting that set_response_params is called with skip_payload=True on a mock, instantiate a real ResponseHandler and assert proto._skip_payload is True after the CONNECT 200 handshake. This validates the actual HTTP parser configuration (response_with_body=False) that prevents tunnel response body bytes from blocking the TLS upgrade (RFC-9110 §9.3.6). Co-Authored-By: Claude Sonnet 4.6 --- tests/test_proxy.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 9ae073a6c02..46aabc71eb4 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -10,6 +10,7 @@ from yarl import URL import aiohttp +from aiohttp.client_proto import ResponseHandler from aiohttp.client_reqrep import ( ClientRequest, ClientRequestArgs, @@ -1184,8 +1185,14 @@ async def test_https_connect_skip_payload_on_200( # type: ignore[misc] Per RFC-9110 §9.3.6 a client MUST ignore any Content-Length or Transfer-Encoding header fields in a successful response to CONNECT. - Verify that set_response_params is called with skip_payload=True so the - HTTP parser does not try to read a body from the 200 tunnel response. + + This test uses a real ResponseHandler instance so we can assert that + ``_skip_payload`` is actually set to ``True`` on the protocol object + after the CONNECT 200 handshake. ``_skip_payload=True`` causes the + underlying ``HttpResponseParser`` to be configured with + ``response_with_body=False``, which means any body bytes advertised + by Content-Length / Transfer-Encoding in the tunnel response are + silently discarded rather than blocking the TLS upgrade. """ event_loop = asyncio.get_running_loop() proxy_req = ClientRequestBase( @@ -1227,7 +1234,10 @@ async def test_https_connect_skip_payload_on_200( # type: ignore[misc] with mock.patch.object( connector, "_resolve_host", autospec=True, return_value=[r] ): - tr, proto = mock.Mock(), mock.Mock() + # Use a real ResponseHandler so we can assert its internal state + # rather than only checking that a mock method was called. + tr = mock.Mock() + proto = ResponseHandler(loop=event_loop) with mock.patch.object( event_loop, "create_connection", @@ -1250,13 +1260,15 @@ async def test_https_connect_skip_payload_on_200( # type: ignore[misc] req, [], aiohttp.ClientTimeout() ) - # proto is the mock protocol returned by create_connection. - # The connector calls protocol.set_response_params(...) on it - # directly, so we assert on proto's auto-mock attribute. - proto.set_response_params.assert_called_once_with( - read_until_eof=True, - timeout_ceil_threshold=mock.ANY, - skip_payload=True, + # Verify that the connector configured the real protocol + # to skip the CONNECT response body. This flag causes + # HttpResponseParser to use response_with_body=False, so + # any Content-Length / Transfer-Encoding bytes in the + # tunnel handshake are not consumed before TLS is started. + assert proto._skip_payload is True, ( + "ResponseHandler._skip_payload must be True after a " + "CONNECT 200 so that proxy body bytes are not read " + "before the TLS upgrade (RFC-9110 §9.3.6)" ) proxy_resp.close()