diff mbox series

[meta-python,wrynose,7/7] python3-aiohttp: fix CVE-2026-111064

Message ID 20261106072702.502813-8-dkelaiya@cisco.com
State New
Headers show
Series python3-aiohttp: Security fixes | expand

Commit Message

From: Darsh Kelaiya <dkelaiya@cisco.com>

Backport the initial idle-connection timeout fix [1] to aiohttp
3.13.5, as identified by [2]. Start the keepalive deadline when the
connection is established so peers that send no request or trickle
incomplete headers cannot keep it open indefinitely.

Retain the existing timeout default and the upstream regressions
for idle peers, trickled headers, disabled deadlines, slow handlers
and WebSocket keepalive cleanup.

[1] https://github.com/aio-libs/aiohttp/commit/be846822b4b3a5a54b6163e794b42dc06fba54a3
[2] https://github.com/aio-libs/aiohttp/security/advisories/GHSA-5wr8-gcqf-gqf3

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

Patch

diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111064.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111064.patch
new file mode 100644
index 0000000000..9962a22ecb
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111064.patch
@@ -0,0 +1,285 @@ 
+From be846822b4b3a5a54b6163e794b42dc06fba54a3 Mon Sep 17 00:00:00 2001
+From: Sam Bull <git@sambull.org>
+Date: Tue, 29 Sep 2026 00:08:22 +0100
+Subject: [PATCH] Close idle connections (#13681) (#13838)
+
+(cherry picked from commit e07684ef9056183bbefba1ae3f717a23f243631e)
+
+CVE: CVE-2026-111064
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/be846822b4b3a5a54b6163e794b42dc06fba54a3]
+
+Backport Changes:
+- Retain aiohttp 3.13.5's documented keepalive_timeout=3630 default
+  instead of changing it to the newer branch's 75-second default.
+- Import Any and AiohttpServer from existing modules for the
+  upstream server and WebSocket regression tests.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ CHANGES/13681.bugfix.rst               |   1 +
+ aiohttp/web_protocol.py                |  12 ++-
+ docs/web_reference.rst                 |   9 ++
+ tests/test_web_server.py               | 118 ++++++++++++++++++++++++-
+ tests/test_web_websocket_functional.py |  34 ++++++-
+ 5 files changed, 167 insertions(+), 7 deletions(-)
+ create mode 100644 CHANGES/13681.bugfix.rst
+
+diff --git a/CHANGES/13681.bugfix.rst b/CHANGES/13681.bugfix.rst
+new file mode 100644
+index 0000000..e39b73a
+--- /dev/null
++++ b/CHANGES/13681.bugfix.rst
+@@ -0,0 +1 @@
++Fixed idle connections not being closed if no request was received -- by :user:`Dreamsorcerer`.
+diff --git a/aiohttp/web_protocol.py b/aiohttp/web_protocol.py
+index d9843f9..c04aa10 100644
+--- a/aiohttp/web_protocol.py
++++ b/aiohttp/web_protocol.py
+@@ -117,8 +117,7 @@ class RequestHandler(BaseProtocol):
+     status line, bad headers or incomplete payload. If any error occurs,
+     connection gets closed.
+ 
+-    keepalive_timeout -- number of seconds before closing
+-                         keep-alive connection
++    keepalive_timeout -- number of seconds before closing an idle connection.
+ 
+     tcp_keepalive -- TCP keep-alive is on, default is on
+ 
+@@ -215,7 +214,7 @@ class RequestHandler(BaseProtocol):
+ 
+         # _request_count is the number of requests processed with the same connection.
+         self._request_count = 0
+-        self._keepalive = False
++        self._keepalive = True
+         self._current_request: Optional[BaseRequest] = None
+         self._manager: Optional[Server] = manager
+         self._request_handler: Optional[_RequestHandler] = manager.request_handler
+@@ -367,6 +366,13 @@ class RequestHandler(BaseProtocol):
+         self._manager.connection_made(self, real_transport)
+ 
+         loop = self._loop
++        # Need to enable keepalive timeout at start of connection, as there's no other
++        # protection against a dead connection that doesn't send a request at all.
++        if self._keepalive_timeout > 0:
++            close_time = loop.time() + self._keepalive_timeout
++            self._next_keepalive_close_time = close_time
++            self._keepalive_handle = loop.call_at(close_time, self._process_keepalive)
++
+         if sys.version_info >= (3, 12):
+             task = asyncio.Task(self.start(), loop=loop, eager_start=True)
+         else:
+diff --git a/docs/web_reference.rst b/docs/web_reference.rst
+index 83a9c7f..ddb43e8 100644
+--- a/docs/web_reference.rst
++++ b/docs/web_reference.rst
+@@ -3135,6 +3135,10 @@ Utilities
+    :param float keepalive_timeout: a delay before a TCP connection is
+                                    closed after a HTTP request. The delay
+                                    allows for reuse of a TCP connection.
++                                   The same delay bounds how long a newly
++                                   accepted connection may take to deliver
++                                   its first complete request; connections
++                                   that stay idle or incomplete are closed.
+ 
+                                    When deployed behind a reverse proxy
+                                    it's important for this value to be
+@@ -3144,6 +3148,11 @@ Utilities
+ 
+       .. versionadded:: 3.8
+ 
++      .. versionchanged:: 3.14.4
++
++         The timeout is now also applied while waiting for the first
++         request, closing connections that never send a complete request.
++
+    :param ssl_context: :class:`ssl.SSLContext` for HTTPS server,
+                        ``None`` for HTTP connection.
+ 
+diff --git a/tests/test_web_server.py b/tests/test_web_server.py
+index 09b7d0b..7595aec 100644
+--- a/tests/test_web_server.py
++++ b/tests/test_web_server.py
+@@ -1,7 +1,7 @@
+ import asyncio
+ import socket
+ from contextlib import suppress
+-from typing import NoReturn
++from typing import Any, NoReturn
+ from unittest import mock
+ 
+ import pytest
+@@ -417,3 +417,119 @@ async def test_no_handler_cancellation(unused_port_socket: socket.socket) -> Non
+         assert done_event.is_set()
+     finally:
+         await asyncio.gather(runner.shutdown(), site.stop())
++
++
++async def test_idle_connection_closed_before_first_request(
++    aiohttp_raw_server: AiohttpRawServer,
++) -> None:
++    """A connection that never sends a request must not be held open forever."""
++
++    async def handler(request: web.BaseRequest) -> web.Response:
++        assert False
++
++    server = await aiohttp_raw_server(handler, keepalive_timeout=0.2)
++
++    reader, writer = await asyncio.open_connection(server.host, server.port)
++    try:
++        # The server must close the connection once keepalive_timeout
++        # expires without a complete request having arrived.
++        assert await asyncio.wait_for(reader.read(), timeout=5) == b""
++    finally:
++        writer.close()
++        await writer.wait_closed()
++
++
++async def test_trickled_headers_closed_at_first_request_deadline(
++    aiohttp_raw_server: AiohttpRawServer, monkeypatch: pytest.MonkeyPatch
++) -> None:
++    """Partial header bytes must not extend the first-request deadline."""
++
++    async def handler(request: web.BaseRequest) -> web.Response:
++        assert False
++
++    server = await aiohttp_raw_server(handler, keepalive_timeout=0.4)
++    runner = server.runner
++    assert runner is not None
++    server_impl = runner.server
++    assert server_impl is not None
++
++    disconnected = asyncio.Event()
++    original_connection_lost = server_impl.connection_lost
++
++    def connection_lost(protocol: Any, exc: BaseException | None = None) -> None:
++        original_connection_lost(protocol, exc)
++        disconnected.set()
++
++    monkeypatch.setattr(server_impl, "connection_lost", connection_lost)
++
++    _, writer = await asyncio.open_connection(server.host, server.port)
++
++    async def trickle_until_closed() -> None:
++        writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\nX-Slow: ")
++        # asyncio's drain() raises a ConnectionError once the connection is
++        # lost; uvloop's write() raises RuntimeError on the closed transport.
++        with pytest.raises((ConnectionError, RuntimeError)):
++            while True:
++                await writer.drain()
++                await asyncio.sleep(0.05)
++                writer.write(b"a")
++
++    try:
++        # The deadline must fire despite the steady trickle of bytes.
++        await asyncio.wait_for(trickle_until_closed(), timeout=5)
++        await asyncio.wait_for(disconnected.wait(), timeout=5)
++        assert not server_impl.connections
++        assert server_impl.requests_count == 0
++    finally:
++        writer.close()
++        with suppress(ConnectionError, RuntimeError):
++            await writer.wait_closed()
++
++
++async def test_keepalive_timeout_zero_no_first_request_deadline(
++    aiohttp_raw_server: AiohttpRawServer,
++) -> None:
++    """keepalive_timeout=0 must not impose a deadline on the first request."""
++
++    async def handler(request: web.BaseRequest) -> web.Response:
++        return web.Response(text="ok")
++
++    server = await aiohttp_raw_server(handler, keepalive_timeout=0)
++
++    reader, writer = await asyncio.open_connection(server.host, server.port)
++    try:
++        # A zero timeout must not arm a deadline that fires at accept time:
++        # the connection has to survive an idle pause and serve the request.
++        await asyncio.sleep(0.1)
++        writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
++        await writer.drain()
++        head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
++        assert head.startswith(b"HTTP/1.1 200 ")
++        # Between requests a zero timeout still closes the idle connection
++        # right after the response, so the body is followed by EOF.
++        assert await asyncio.wait_for(reader.read(), timeout=5) == b"ok"
++    finally:
++        writer.close()
++        await writer.wait_closed()
++
++
++async def test_handler_slower_than_first_request_deadline(
++    aiohttp_raw_server: AiohttpRawServer,
++) -> None:
++    """A parsed request being handled is not subject to the idle deadline."""
++
++    async def handler(request: web.BaseRequest) -> web.Response:
++        await asyncio.sleep(0.4)  # Longer than keepalive_timeout.
++        return web.Response(text="ok")
++
++    server = await aiohttp_raw_server(handler, keepalive_timeout=0.2)
++
++    reader, writer = await asyncio.open_connection(server.host, server.port)
++    try:
++        writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
++        await writer.drain()
++        head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
++        assert head.startswith(b"HTTP/1.1 200 ")
++    finally:
++        writer.close()
++        await writer.wait_closed()
+diff --git a/tests/test_web_websocket_functional.py b/tests/test_web_websocket_functional.py
+index f7f5c31..871987e 100644
+--- a/tests/test_web_websocket_functional.py
++++ b/tests/test_web_websocket_functional.py
+@@ -12,7 +12,7 @@ import pytest
+ import aiohttp
+ from aiohttp import web
+ from aiohttp.http import WSCloseCode, WSMsgType
+-from aiohttp.pytest_plugin import AiohttpClient
++from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer
+ 
+ 
+ async def test_websocket_can_prepare(loop, aiohttp_client) -> None:
+@@ -953,9 +953,37 @@ async def test_websocket_disable_keepalive(loop, aiohttp_client) -> None:
+     assert data == "OK"
+ 
+ 
+-async def test_receive_str_nonstring(
+-    loop: asyncio.AbstractEventLoop, aiohttp_client: AiohttpClient
++async def test_websocket_disable_keepalive_no_armed_handle(
++    aiohttp_server: AiohttpServer, aiohttp_client: AiohttpClient
+ ) -> None:
++    """keep_alive(False) when no keepalive timer was armed (keepalive_timeout=0)."""
++
++    async def handler(request: web.Request) -> web.StreamResponse:
++        ws = web.WebSocketResponse()
++        assert ws.can_prepare(request)
++        # A zero timeout arms no deadline at connection_made().
++        assert request.protocol._keepalive_handle is None
++        await ws.prepare(request)
++        assert not request.protocol._keepalive
++        assert request.protocol._keepalive_handle is None
++
++        await ws.send_str("OK")
++        await ws.close()
++        return ws
++
++    app = web.Application()
++    app.router.add_route("GET", "/", handler)
++    server = await aiohttp_server(app, keepalive_timeout=0)
++    client = await aiohttp_client(server)  # type: ignore[var-annotated]
++
++    ws = await client.ws_connect("/")
++    data = await ws.receive_str()
++    assert data == "OK"
++
++    await ws.receive()  # Handle close
++
++
++async def test_receive_str_nonstring(aiohttp_client: AiohttpClient) -> None:
+     async def handler(request: web.Request) -> web.WebSocketResponse:
+         ws = web.WebSocketResponse()
+         assert ws.can_prepare(request)
+-- 
+2.35.6
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
index c6167ef607..98fd4bdba3 100644
--- a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
+++ b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
@@ -31,6 +31,7 @@  SRC_URI += " \
     file://CVE-2026-111748_p2.patch \
     file://CVE-2026-111063_p1.patch \
     file://CVE-2026-111063_p2.patch \
+    file://CVE-2026-111064.patch \
 "
 
 CVE_PRODUCT = "aiohttp:aiohttp"