new file mode 100644
@@ -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
@@ -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"