new file mode 100644
@@ -0,0 +1,307 @@
+From 3267a26ebb0c28cc70b0caf5836d20ff1b7c2f39 Mon Sep 17 00:00:00 2001
+From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com>
+Date: Tue, 30 Jun 2026 20:21:57 +0100
+Subject: [PATCH] [PR #13016/8ef76ab8 backport][3.14] Fix body reads after
+ failed WS upgrade (#13017)
+
+This is a backport of PR #13016 as merged into master
+(8ef76ab8029d0f2d88449fe115feb42ded646c7c).
+
+CVE: CVE-2026-69243
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/6ae358f0983c3f4d6f67692b2f8e65dc8e091c98]
+
+Backport Changes:
+- Adapted deferred upgrades to the 3.13.5 llhttp pause and tail
+ buffering path, including the CVE-2026-54273 queue limit.
+- Adapted the pure Python parser to its boolean payload result.
+- Used the 3.13.5 buffer size in the earlier 54273 test.
+- Kept the existing pipelined-upgrade regression test and added
+ the upstream body-read regression test.
+- Omitted generated _http_parser.c because the Wrynose recipe
+ regenerates it from _http_parser.pyx during do_configure.
+
+Co-authored-by: Sam Bull <git@sambull.org>
+(cherry picked from commit 6ae358f0983c3f4d6f67692b2f8e65dc8e091c98)
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ CHANGES/13016.bugfix.rst | 1 +
+ aiohttp/_http_parser.pyx | 20 +++++++++----
+ aiohttp/http_parser.py | 21 ++++++++++----
+ tests/test_http_parser.py | 48 ++++++++++++++++++++++++++++++++
+ tests/test_web_functional.py | 54 ++++++++++++++++++++++++++++++++++--
+ 5 files changed, 131 insertions(+), 13 deletions(-)
+ create mode 100644 CHANGES/13016.bugfix.rst
+
+diff --git a/CHANGES/13016.bugfix.rst b/CHANGES/13016.bugfix.rst
+new file mode 100644
+index 000000000..a984f64e3
+--- /dev/null
++++ b/CHANGES/13016.bugfix.rst
+@@ -0,0 +1 @@
++Fixed request body not being read on rejected WebSocket upgrades -- by :user:`Dreamsorcerer`.
+diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx
+index 0d0762748..478d82ecb 100644
+--- a/aiohttp/_http_parser.pyx
++++ b/aiohttp/_http_parser.pyx
+@@ -318,6 +318,7 @@ cdef class HttpParser:
+ set _seen_singletons
+ list _raw_headers
+ bint _upgraded
++ bint _pending_upgrade
+ list _messages
+ Py_ssize_t _msg_in_flight
+ Py_ssize_t _max_msg_queue_size
+@@ -387,6 +388,7 @@ cdef class HttpParser:
+ self._response_with_body = response_with_body
+ self._read_until_eof = read_until_eof
+ self._upgraded = False
++ self._pending_upgrade = False
+ self._auto_decompress = auto_decompress
+ self._content_encoding = None
+ self._lax = False
+@@ -466,10 +468,13 @@ cdef class HttpParser:
+ h_upg = headers.get("upgrade", "")
+ allowed = upgrade and h_upg.isascii() and h_upg.lower() in ALLOWED_UPGRADES
+ if allowed or self._cparser.method == cparser.HTTP_CONNECT:
+- self._upgraded = True
++ # Wait until the complete request body has been parsed.
++ self._pending_upgrade = True
+ else:
+ if upgrade and self._cparser.status_code == 101:
+- self._upgraded = True
++ # llhttp pauses for a 101 on its own; just mark the pending
++ # switch so feed_data returns the upgraded-protocol tail.
++ self._pending_upgrade = True
+
+ # do not support old websocket spec
+ if SEC_WEBSOCKET_KEY1 in headers:
+@@ -597,6 +602,10 @@ cdef class HttpParser:
+ if errno is cparser.HPE_PAUSED_UPGRADE:
+ cparser.llhttp_resume_after_upgrade(self._cparser)
+ nb = cparser.llhttp_get_error_pos(self._cparser) - base
++ if self._pending_upgrade:
++ # A supported upgrade whose request body has now been fully read.
++ self._upgraded = True
++ self._pending_upgrade = False
+ elif errno is cparser.HPE_PAUSED:
+ cparser.llhttp_resume(self._cparser)
+ pos = cparser.llhttp_get_error_pos(self._cparser) - base
+@@ -818,10 +827,9 @@ cdef int cb_on_headers_complete(cparser.llhttp_t* parser) except -1:
+ pyparser._last_error = exc
+ return -1
+ else:
+- if pyparser._upgraded or pyparser._cparser.method == cparser.HTTP_CONNECT:
+- return 2
+- else:
+- return 0
++ if not pyparser._response_with_body:
++ return 1
++ return 0
+
+
+ cdef int cb_on_body(cparser.llhttp_t* parser,
+diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py
+index 32375f83a..f4458dc9d 100644
+--- a/aiohttp/http_parser.py
++++ b/aiohttp/http_parser.py
+@@ -289,6 +289,7 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ self._lines: List[bytes] = []
+ self._tail = b""
+ self._upgraded = False
++ self._pending_upgrade = False
+ self._payload = None
+ self._payload_parser: Optional[HttpPayloadParser] = None
+ self._auto_decompress = auto_decompress
+@@ -417,9 +418,7 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ if SEC_WEBSOCKET_KEY1 in msg.headers:
+ raise InvalidHeader(SEC_WEBSOCKET_KEY1)
+
+- self._upgraded = msg.upgrade and _is_supported_upgrade(
+- msg.headers
+- )
++ upgraded = msg.upgrade and _is_supported_upgrade(msg.headers)
+
+ method = getattr(msg, "method", self.method)
+ # code is only present on responses
+@@ -431,8 +430,7 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ method and method in EMPTY_BODY_METHODS
+ )
+ if not empty_body and (
+- ((length is not None and length > 0) or msg.chunked)
+- and not self._upgraded
++ (length is not None and length > 0) or msg.chunked
+ ):
+ payload = StreamReader(
+ self.protocol,
+@@ -457,6 +455,10 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ )
+ if not payload_parser.done:
+ self._payload_parser = payload_parser
++ # https://www.rfc-editor.org/info/rfc9110/#section-7.8-15
++ # Defer any requested upgrade until the
++ # complete request has been read.
++ self._pending_upgrade = upgraded
+ elif method == METH_CONNECT:
+ assert isinstance(msg, RawRequestMessage)
+ payload = StreamReader(
+@@ -501,6 +503,11 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ )
+ if not payload_parser.done:
+ self._payload_parser = payload_parser
++ elif upgraded:
++ # No body to read, so the connection switches to
++ # the upgraded protocol immediately.
++ self._upgraded = True
++ payload = EMPTY_PAYLOAD
+ else:
+ payload = EMPTY_PAYLOAD
+
+@@ -548,6 +555,10 @@ class HttpParser(abc.ABC, Generic[_MsgT]):
+ start_pos = 0
+ data_len = len(data)
+ self._payload_parser = None
++ if self._pending_upgrade:
++ # The body is complete; the protocol switch may begin.
++ self._upgraded = True
++ self._pending_upgrade = False
+ continue
+ else:
+ break
+diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py
+index a4e6bc056..92e59af1c 100644
+--- a/tests/test_http_parser.py
++++ b/tests/test_http_parser.py
+@@ -1217,6 +1217,54 @@ async def test_http_request_upgrade_unknown(parser: Any) -> None:
+ assert await messages[0][-1].read() == b"{}"
+
+
++@pytest.mark.parametrize("chunked", (False, True), ids=("content-length", "chunked"))
++async def test_http_request_upgrade_with_body_read(
++ parser: HttpRequestParser, chunked: bool
++) -> None:
++ body_request = b"foobarbaz\r\n\r\n"
++ if chunked:
++ framing = b"Transfer-Encoding: chunked\r\n"
++ body = b"%x\r\n%s\r\n0\r\n\r\n" % (len(body_request), body_request)
++ else:
++ framing = b"Content-Length: %d\r\n" % len(body_request)
++ body = body_request
++ after = b"GET /after HTTP/1.1\r\nHost: a\r\n\r\n"
++ text = (
++ b"GET /ws HTTP/1.1\r\nHost: a\r\n"
++ b"Connection: Upgrade\r\nUpgrade: websocket\r\n"
++ + framing
++ + b"\r\n"
++ + body
++ + after
++ )
++ messages, upgrade, tail = parser.feed_data(text)
++ assert len(messages) == 1
++ msg, payload = messages[0]
++ assert msg.method == "GET"
++ assert msg.path == "/ws"
++ assert msg.upgrade
++ assert await payload.read() == body_request
++ # The connection switches protocols only after the body is fully read.
++ assert upgrade
++ assert tail == after
++
++
++def test_http_request_upgrade_empty_body_allowed(parser: HttpRequestParser) -> None:
++ text = (
++ b"GET /ws HTTP/1.1\r\n"
++ b"Host: a\r\n"
++ b"Connection: Upgrade\r\n"
++ b"Upgrade: websocket\r\n"
++ b"Content-Length: 0\r\n\r\n"
++ b"some raw data"
++ )
++ messages, upgrade, tail = parser.feed_data(text)
++ msg = messages[0][0]
++ assert msg.upgrade
++ assert upgrade
++ assert tail == b"some raw data"
++
++
+ @pytest.fixture
+ def xfail_c_parser_url(request) -> None:
+ if isinstance(request.getfixturevalue("parser"), HttpRequestParserPy):
+diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py
+index b958bef9d..0d2824a9c 100644
+--- a/tests/test_web_functional.py
++++ b/tests/test_web_functional.py
+@@ -25,7 +25,6 @@ from aiohttp import (
+ )
+ from aiohttp.compression_utils import ZLibBackend, ZLibCompressObjProtocol
+ from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING
+-from aiohttp.helpers import DEFAULT_CHUNK_SIZE
+ from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer
+ from aiohttp.streams import StreamReader
+ from aiohttp.typedefs import Handler
+@@ -1923,7 +1922,7 @@ async def test_unread_compressed_body_drain_is_bounded(
+ await writer.wait_closed()
+
+ # Bounded by the buffer, not the decompressed size.
+- assert max(drain_reads) <= 3 * DEFAULT_CHUNK_SIZE
++ assert max(drain_reads) <= 3 * 2**16
+ assert max(drain_reads) < decompressed_size
+
+
+@@ -2624,3 +2623,54 @@ async def test_keepalive_expires_on_time(aiohttp_client: AiohttpClient) -> None:
+
+ # Ensure the keep alive handle expires
+ assert request_handler._keepalive_handle is None
++
++
++async def test_declined_websocket_upgrade_reads_body(
++ aiohttp_server: AiohttpServer,
++) -> None:
++ body_read = b""
++
++ async def ws_handler(request: web.Request) -> web.Response:
++ nonlocal body_read
++ # Decline the upgrade; read the body as a normal handler may.
++ body_read = await request.read()
++ return web.Response(text="declined")
++
++ async def after_handler(request: web.Request) -> web.Response:
++ return web.Response(text="after")
++
++ app = web.Application()
++ app.router.add_get("/ws", ws_handler)
++ app.router.add_get("/after", after_handler)
++ server = await aiohttp_server(app)
++
++ body = b"FooBarBaz\r\n\r\n"
++ # Use raw connection in order to pipeline requests.
++ pipeline = (
++ (
++ b"GET /ws HTTP/1.1\r\n"
++ b"Host: localhost\r\n"
++ b"Connection: Upgrade\r\n"
++ b"Upgrade: websocket\r\n"
++ b"Content-Length: %d\r\n\r\n" % len(body)
++ )
++ + body
++ + b"GET /after HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
++ )
++
++ reader, writer = await asyncio.open_connection(server.host, server.port)
++ try:
++ writer.write(pipeline)
++ await writer.drain()
++ # The trailing request sends Connection: close, so the server closes
++ # once it has answered it -- reading to EOF gathers both responses.
++ response = await asyncio.wait_for(reader.read(), 5)
++ finally:
++ writer.close()
++ with suppress(ConnectionResetError, BrokenPipeError):
++ await writer.wait_closed()
++
++ assert body_read == body
++ # Both the upgrade request and the pipelined request were served.
++ assert response.count(b"HTTP/1.") == 2, response
++ assert response.count(b" 200 ") == 2, response
+--
+2.35.6
+
@@ -20,6 +20,7 @@ SRC_URI += " \
file://CVE-2026-54273_p1.patch \
file://CVE-2026-54273_p2.patch \
file://CVE-2026-59881.patch \
+ file://CVE-2026-69243.patch \
"
CVE_PRODUCT = "aiohttp:aiohttp"