From 804b9d48880222f72b98943a5ecf06d98b1c5074 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jun 2026 00:39:29 -0500 Subject: [PATCH] [PR #12828/13b635d7 backport][3.14] Bounded unread compressed drain (#12845) CVE: CVE-2026-54278 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/4f7480e474cccc6a8cc2c92ad3f17a31dedf8232] Backport Changes: - aiohttp 3.9.5's earlier decompression backport can buffer one decompressed chunk larger than the read buffer. In addition to snapshotting the initial chunk count, cap an unbounded read at StreamReader._high_water so one such chunk cannot be returned as a multi-megabyte bytes object. - aiohttp 3.9.5 does not expose DEFAULT_CHUNK_SIZE. Use its equivalent 64 KiB read-buffer value directly in the functional test. - Import the existing AiohttpClient and AiohttpServer aliases from aiohttp.pytest_plugin because this branch's test module did not already import them. - Mark the mock protocol paused in test_readany_does_not_drain_reentrant_refill because aiohttp 3.9.5 gates low-water resume_reading() calls on that state. - Send the functional test's body after receiving the early 401 response so aiohttp 3.9.5 deterministically processes it through lingering cleanup. (cherry picked from commit 4f7480e474cccc6a8cc2c92ad3f17a31dedf8232) Signed-off-by: Darsh Kelaiya --- CHANGES/12828.bugfix.rst | 1 + aiohttp/streams.py | 23 ++++++++++--- tests/test_streams.py | 29 ++++++++++++++++ tests/test_web_functional.py | 64 ++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 CHANGES/12828.bugfix.rst diff --git a/CHANGES/12828.bugfix.rst b/CHANGES/12828.bugfix.rst new file mode 100644 index 000000000..9893577a5 --- /dev/null +++ b/CHANGES/12828.bugfix.rst @@ -0,0 +1 @@ +Fixed :meth:`~aiohttp.StreamReader.readany` and :meth:`~aiohttp.StreamReader.read_nowait` joining data fed back into the buffer during the call (when draining below the low water mark resumes reading) into a single unbounded :class:`bytes`; a call now returns only the chunks that were buffered when it started, keeping the drain of an unread auto-decompressed request body bounded by the read buffer -- by :user:`bdraco`. diff --git a/aiohttp/streams.py b/aiohttp/streams.py index dffaf374b..0d4d633ef 100644 --- a/aiohttp/streams.py +++ b/aiohttp/streams.py @@ -526,14 +526,27 @@ class StreamReader(AsyncStreamReaderMixin): """Read not more than n bytes, or whole buffer if n == -1""" self._timer.assert_timeout() - chunks = [] - while self._buffer: - chunk = self._read_nowait_chunk(n) - chunks.append(chunk) - if n != -1: + if n == -1: + # Drain only chunks present now; _read_nowait_chunk() can + # re-entrantly resume_reading() and refill the buffer. + count = len(self._buffer) + n = self._high_water + chunks = [] + for _ in range(count): + chunk = self._read_nowait_chunk(n) + chunks.append(chunk) n -= len(chunk) if n == 0: break + return b"".join(chunks) + + chunks: list[bytes] = [] + while self._buffer: + chunk = self._read_nowait_chunk(n) + chunks.append(chunk) + n -= len(chunk) + if n == 0: + break return b"".join(chunks) if chunks else b"" diff --git a/tests/test_streams.py b/tests/test_streams.py index 2076bc9ba..e35dc0709 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1721,3 +1721,32 @@ async def test_stream_reader_small_limit_resumes_reading( protocol.resume_reading.assert_called() assert protocol._reading_paused is False + + +async def test_readany_does_not_drain_reentrant_refill( + protocol: mock.Mock, +) -> None: + """A single readany() must not reassemble data fed re-entrantly. + + Draining below the low water mark resumes reading, which can synchronously + refill the buffer (e.g. decompressing another chunk). Joining that refill in + one call would reassemble an unbounded body. + """ + loop = asyncio.get_running_loop() + stream = streams.StreamReader(protocol, limit=4, loop=loop) + + refills = [b"second", b"third"] + + def resume_reading() -> None: + if refills: + stream.feed_data(refills.pop(0)) + + protocol.resume_reading.side_effect = resume_reading + + protocol._reading_paused = True + stream.feed_data(b"first") + + # Popping "first" refills "second", but this readany() returns only "first". + assert await stream.readany() == b"first" + assert await stream.readany() == b"second" + assert await stream.readany() == b"third" diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py index 96dcd1c98..e6d01bffd 100644 --- a/tests/test_web_functional.py +++ b/tests/test_web_functional.py @@ -4,6 +4,7 @@ import json import pathlib import socket import zlib +from contextlib import suppress from typing import Any, Optional from unittest import mock @@ -22,6 +23,8 @@ from aiohttp import ( web, ) from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING +from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer +from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_coro from aiohttp.typedefs import Handler @@ -1617,6 +1620,67 @@ async def test_response_prepared_with_clone(aiohttp_client) -> None: await resp.release() +@pytest.mark.parametrize("decompressed_size", [4 * 1024 * 1024, 32 * 1024 * 1024]) +async def test_unread_compressed_body_drain_is_bounded( + aiohttp_server: AiohttpServer, + monkeypatch: pytest.MonkeyPatch, + decompressed_size: int, +) -> None: + """Draining an unread compressed body stays bounded by the read buffer. + + A handler that rejects before reading still drains the payload during + lingering close; a small compressed body must not force a large transient + allocation (a deflate-bomb style DoS). + """ + drain_reads: list[int] = [] + drained = asyncio.Event() + readany = StreamReader.readany + + async def record_readany(self: StreamReader) -> bytes: + data = await readany(self) + assert data + drain_reads.append(len(data)) + drained.set() + return data + + monkeypatch.setattr(StreamReader, "readany", record_readany) + + async def handler(request: web.Request) -> web.Response: + return web.Response(status=401) + + app = web.Application(client_max_size=1024) + app.router.add_post("/", handler) + server = await aiohttp_server(app) + + body = zlib.compress(b"a" * decompressed_size) + assert len(body) < decompressed_size + head = ( + b"POST / HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Encoding: deflate\r\n" + b"Content-Length: %d\r\n" + b"Connection: keep-alive\r\n\r\n" + ) % len(body) + + reader, writer = await asyncio.open_connection(server.host, server.port) + try: + writer.write(head) + await writer.drain() + status_line = await asyncio.wait_for(reader.readline(), 5) + assert status_line.startswith(b"HTTP/1.1 401 ") + writer.write(body) + await writer.drain() + await asyncio.wait_for(drained.wait(), 5) + finally: + writer.close() + with suppress(ConnectionResetError, BrokenPipeError): + await writer.wait_closed() + + # Bounded by the buffer, not the decompressed size. + assert max(drain_reads) <= 3 * 2**16 + assert max(drain_reads) < decompressed_size + + async def test_app_max_client_size(aiohttp_client) -> None: async def handler(request): await request.post() -- 2.35.6