From 724ae4d652417c4490a71ea0b16b8804dad3004f Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:47:16 +0000 Subject: [PATCH] [PR #12831/1ac92dae backport][3.14] Payload close on disconnect (#12843) CVE: CVE-2026-54280 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/a762eda5242f6490d6ba667533193f8b473ad587] Co-authored-by: J. Nick Koston (cherry picked from commit a762eda5242f6490d6ba667533193f8b473ad587) Signed-off-by: Darsh Kelaiya --- CHANGES/12831.bugfix.rst | 1 + aiohttp/web_response.py | 6 ++-- tests/test_web_response.py | 73 +++++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 CHANGES/12831.bugfix.rst diff --git a/CHANGES/12831.bugfix.rst b/CHANGES/12831.bugfix.rst new file mode 100644 index 000000000..bf460ffcc --- /dev/null +++ b/CHANGES/12831.bugfix.rst @@ -0,0 +1 @@ +Fixed :meth:`aiohttp.web.Response.write_eof` skipping ``Payload.close()`` when the body write was interrupted by an error or cancellation, for example when a client disconnects mid-response; the payload close hook now runs in a ``finally`` so a :class:`~aiohttp.payload.Payload` body always releases its resources -- by :user:`bdraco`. diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index 364270e4d..cea5d4b45 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -779,8 +779,10 @@ class Response(StreamResponse): if body is None or self._must_be_empty_body: await super().write_eof() elif isinstance(self._body, Payload): - await self._body.write(self._payload_writer) - await self._body.close() + try: + await self._body.write(self._payload_writer) + finally: + await self._body.close() await super().write_eof() else: await super().write_eof(cast(bytes, body)) diff --git a/tests/test_web_response.py b/tests/test_web_response.py index 5a4fb7e66..f094cd3d2 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -1,3 +1,4 @@ +import asyncio import collections.abc import datetime import gzip @@ -18,7 +19,7 @@ from aiohttp.abc import AbstractStreamWriter from aiohttp.helpers import ETag from aiohttp.http_writer import StreamWriter, _serialize_headers from aiohttp.multipart import BodyPartReader, MultipartWriter -from aiohttp.payload import BytesPayload, StringPayload +from aiohttp.payload import BytesPayload, Payload, StringPayload from aiohttp.test_utils import make_mocked_request from aiohttp.web import ContentCoding, Response, StreamResponse, json_response @@ -1370,6 +1371,76 @@ async def test_consecutive_write_eof() -> None: writer.write_eof.assert_called_once_with(data) +class _ClosingPayload(Payload): + """Payload test double that records whether close() ran.""" + + def __init__(self) -> None: + super().__init__(None) + self.close_called = False + self.started = asyncio.Event() + self.release = asyncio.Event() + self.fail = False + + async def write(self, writer: AbstractStreamWriter) -> None: + self.started.set() + if self.fail: + raise ConnectionResetError("client gone") + await self.release.wait() + + async def close(self) -> None: + self.close_called = True + await super().close() + + def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: + assert False + + +async def test_write_eof_closes_payload_on_success() -> None: + writer = mock.create_autospec(AbstractStreamWriter, spec_set=True, instance=True) + req = make_request("GET", "/", writer=writer) + payload = _ClosingPayload() + payload.release.set() + resp = web.Response(body=payload) + + await resp.prepare(req) + await resp.write_eof() + + assert payload.close_called + assert writer.write_eof.called + + +async def test_write_eof_closes_payload_on_write_error() -> None: + writer = mock.create_autospec(AbstractStreamWriter, spec_set=True, instance=True) + req = make_request("GET", "/", writer=writer) + payload = _ClosingPayload() + payload.fail = True + resp = web.Response(body=payload) + + await resp.prepare(req) + with pytest.raises(ConnectionResetError): + await resp.write_eof() + + assert payload.close_called + assert not writer.write_eof.called + + +async def test_write_eof_closes_payload_on_cancel() -> None: + writer = mock.create_autospec(AbstractStreamWriter, spec_set=True, instance=True) + req = make_request("GET", "/", writer=writer) + payload = _ClosingPayload() + resp = web.Response(body=payload) + + await resp.prepare(req) + task = asyncio.ensure_future(resp.write_eof()) + await payload.started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert payload.close_called + assert not writer.write_eof.called + + def test_set_text_with_content_type() -> None: resp = Response() resp.content_type = "text/html"