diff mbox series

[meta-python,scarthgap,11/13] python3-aiohttp: fix CVE-2026-54278

Message ID 20260928174323.1810308-12-dkelaiya@cisco.com
State New
Headers show
Series python3-aiohttp: fix multiple CVEs | expand

Commit Message

From: Darsh Kelaiya <dkelaiya@cisco.com>

This patch applies the reviewed upstream fix shown in [1]. The
advisory identifying the fix is referenced in [2].

[1] https://github.com/aio-libs/aiohttp/commit/4f7480e474cccc6a8cc2c92ad3f17a31dedf8232
[2] https://nvd.nist.gov/vuln/detail/CVE-2026-54278

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
 .../python3-aiohttp/CVE-2026-54278.patch      | 209 ++++++++++++++++++
 .../python/python3-aiohttp_3.9.5.bb           |   1 +
 2 files changed, 210 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54278.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54278.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54278.patch
new file mode 100644
index 0000000000..f5ab4f775b
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-54278.patch
@@ -0,0 +1,209 @@ 
+From 804b9d48880222f72b98943a5ecf06d98b1c5074 Mon Sep 17 00:00:00 2001
+From: "J. Nick Koston" <nick@koston.org>
+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 <dkelaiya@cisco.com>
+---
+ 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
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb b/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb
index 98dd5363db..f91c9cb181 100644
--- a/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb
+++ b/meta-python/recipes-devtools/python/python3-aiohttp_3.9.5.bb
@@ -32,6 +32,7 @@  SRC_URI += "file://CVE-2024-52304.patch \
            file://CVE-2026-54274.patch \
            file://CVE-2026-54275.patch \
            file://CVE-2026-54277.patch \
+           file://CVE-2026-54278.patch \
            "
 
 CVE_STATUS[CVE-2026-34515] = "not-applicable-platform: Vulnerability only affects applications running on Windows"