diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p1.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p1.patch new file mode 100644 index 0000000000..8b6e29bbfb --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p1.patch @@ -0,0 +1,400 @@ +From 2d94633b7f1b4c1ba07d647b629a8440652b2d09 Mon Sep 17 00:00:00 2001 +From: Sam Bull +Date: Tue, 18 Aug 2026 21:54:45 +0100 +Subject: [PATCH] Reduce number of file descriptors needed in multipart + messages (#13426) (#13475) + +(cherry picked from commit c92ed4c48dafd40d5ed4c86587af9e081ac7d971) + +CVE: CVE-2026-111066 +Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/2d94633b7f1b4c1ba07d647b629a8440652b2d09] + +Backport Changes: +- Pass the raw payload size to the older HTTP 413 constructor and + update the existing test to match upstream's size-limit assertion. +- Keep the existing type annotations and surrounding test signatures. + +Signed-off-by: Darsh Kelaiya +--- + CHANGES/13426.bugfix.rst | 1 + + aiohttp/web_request.py | 96 +++++++++++++++----- + tests/test_web_functional.py | 167 ++++++++++++++++++++++++++++++++++- + 3 files changed, 237 insertions(+), 27 deletions(-) + create mode 100644 CHANGES/13426.bugfix.rst + +diff --git a/CHANGES/13426.bugfix.rst b/CHANGES/13426.bugfix.rst +new file mode 100644 +index 0000000..8b9ee29 +--- /dev/null ++++ b/CHANGES/13426.bugfix.rst +@@ -0,0 +1 @@ ++Switched multipart handling to use spooled temporary files to reduce number of file descriptors needed -- by :user:`Dreamsorcerer`. +diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py +index f2d4c51..e978b2a 100644 +--- a/aiohttp/web_request.py ++++ b/aiohttp/web_request.py +@@ -4,11 +4,12 @@ import io + import re + import socket + import string +-import tempfile ++import sys + import types + import warnings + from types import MappingProxyType + from typing import ( ++ IO, + TYPE_CHECKING, + Any, + Dict, +@@ -64,6 +65,30 @@ from .typedefs import ( + from .web_exceptions import HTTPRequestEntityTooLarge + from .web_response import StreamResponse + ++if sys.version_info >= (3, 11): ++ from tempfile import SpooledTemporaryFile ++else: ++ import tempfile ++ ++ class SpooledTemporaryFile(tempfile.SpooledTemporaryFile[bytes], io.IOBase): ++ """Make the spooled file satisfy the documented `FileField.file` type. ++ ++ `tempfile.SpooledTemporaryFile` only became an `io.IOBase` subclass in ++ 3.11 (python/cpython#70363), and never grew the three capability ++ predicates before then. Both underlying files (`io.BytesIO` before ++ rollover, a `w+b` temp file after) are readable, writable and seekable. ++ """ ++ ++ def readable(self) -> bool: ++ return True ++ ++ def writable(self) -> bool: ++ return True ++ ++ def seekable(self) -> bool: ++ return True ++ ++ + __all__ = ("BaseRequest", "FileField", "Request") + + +@@ -73,11 +98,18 @@ if TYPE_CHECKING: + from .web_urldispatcher import UrlMappingMatchInfo + + ++# Multipart file parts are buffered in memory and only spilled to a temporary ++# file once they outgrow this size. A temp file per part would let a body made ++# of many tiny parts exhaust the process's file descriptors; spooling bounds ++# that at ``client_max_size // _FILE_SPOOL_MAX_SIZE`` descriptors per request. ++_FILE_SPOOL_MAX_SIZE: Final[int] = 1024**2 ++ ++ + @attr.s(auto_attribs=True, frozen=True, slots=True) + class FileField: + name: str + filename: str +- file: io.BufferedReader ++ file: IO[bytes] + content_type: str + headers: CIMultiDictProxy[str] + +@@ -726,8 +758,15 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): + multipart = await self.multipart() + max_size = self._client_max_size + +- size = 0 ++ payload = self._payload + while (field := await multipart.next()) is not None: ++ # This check is needed for empty payloads, which still add ++ # overhead without entering the loop and the check below. ++ if 0 < max_size < payload.total_bytes: ++ raise HTTPRequestEntityTooLarge( ++ max_size=max_size, actual_size=payload.total_bytes ++ ) ++ + field_ct = field.headers.get(hdrs.CONTENT_TYPE) + + if isinstance(field, BodyPartReader): +@@ -739,22 +778,37 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): + # present. + # https://tools.ietf.org/html/rfc7578#section-4.4 + if field.filename: +- # store file in temp file +- tmp = await self._loop.run_in_executor( +- None, tempfile.TemporaryFile +- ) ++ tmp = SpooledTemporaryFile(_FILE_SPOOL_MAX_SIZE) ++ # rolled means the temp file now uses the disk, at ++ # which point we want to run in the executor. ++ rolled = False + while chunk := await field.read_chunk(size=2**18): ++ # Bounds one part, mid-read. ++ if 0 < max_size < payload.total_bytes: ++ if rolled: ++ await self._loop.run_in_executor(None, tmp.close) ++ else: ++ tmp.close() ++ raise HTTPRequestEntityTooLarge( ++ max_size=max_size, actual_size=payload.total_bytes ++ ) + async for decoded_chunk in field.decode_iter(chunk): +- await self._loop.run_in_executor( +- None, tmp.write, decoded_chunk ++ # Update before writing, so we know if next ++ # write is going to hit the disk. ++ rolled = rolled or ( ++ tmp.tell() + len(decoded_chunk) ++ > _FILE_SPOOL_MAX_SIZE + ) +- size += len(decoded_chunk) +- if 0 < max_size < size: +- await self._loop.run_in_executor(None, tmp.close) +- raise HTTPRequestEntityTooLarge( +- max_size=max_size, actual_size=size ++ if rolled: ++ await self._loop.run_in_executor( ++ None, tmp.write, decoded_chunk + ) +- await self._loop.run_in_executor(None, tmp.seek, 0) ++ else: ++ tmp.write(decoded_chunk) ++ if rolled: ++ await self._loop.run_in_executor(None, tmp.seek, 0) ++ else: ++ tmp.seek(0) + + if field_ct is None: + field_ct = "application/octet-stream" +@@ -762,7 +816,7 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): + ff = FileField( + field.name, + field.filename, +- cast(io.BufferedReader, tmp), ++ tmp, + field_ct, + field.headers, + ) +@@ -771,10 +825,9 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): + # deal with ordinary data + raw_data = bytearray() + while chunk := await field.read_chunk(): +- size += len(chunk) +- if 0 < max_size < size: ++ if 0 < max_size < payload.total_bytes: + raise HTTPRequestEntityTooLarge( +- max_size=max_size, actual_size=size ++ max_size=max_size, actual_size=payload.total_bytes + ) + raw_data.extend(chunk) + +@@ -843,10 +896,7 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): + if self._post is None or self.content_type != "multipart/form-data": + return + +- # NOTE: Release file descriptors for the +- # NOTE: `tempfile.Temporaryfile`-created `_io.BufferedRandom` +- # NOTE: instances of files sent within multipart request body +- # NOTE: via HTTP POST request. ++ # Release the temp files created within multipart request body. + for file_name, file_field_object in self._post.items(): + if isinstance(file_field_object, FileField): + file_field_object.file.close() +diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py +index 0d2824a..a314d9b 100644 +--- a/tests/test_web_functional.py ++++ b/tests/test_web_functional.py +@@ -1,6 +1,8 @@ + import asyncio ++import base64 + import io + import json ++import os + import pathlib + import socket + import sys +@@ -29,6 +31,7 @@ from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer + from aiohttp.streams import StreamReader + from aiohttp.typedefs import Handler + from aiohttp.web_protocol import MAX_MSG_QUEUE_SIZE, RequestHandler ++from aiohttp.web_request import _FILE_SPOOL_MAX_SIZE + + try: + import brotlicffi as brotli +@@ -2023,10 +2026,7 @@ async def test_post_max_client_size(aiohttp_client) -> None: + + assert 413 == resp.status + resp_text = await resp.text() +- assert ( +- "Maximum request body size 10 exceeded, " +- "actual body size 1024" in resp_text +- ) ++ assert "Maximum request body size 10 exceeded" in resp_text + data["file"].close() + + await resp.release() +@@ -2050,6 +2050,165 @@ async def test_post_max_client_size_for_file(aiohttp_client) -> None: + await resp.release() + + ++def _multipart_file_parts(count: int, body: bytes = b"") -> bytes: ++ """Raw multipart/form-data body of ``count`` file parts, boundary ``b``.""" ++ return ( ++ b"".join( ++ b'--b\r\nContent-Disposition: form-data; name="f%d"; ' ++ b'filename="x"\r\n\r\n' % index + body + b"\r\n" ++ for index in range(count) ++ ) ++ + b"--b--\r\n" ++ ) ++ ++ ++async def test_post_max_client_size_counts_multipart_headers( ++ aiohttp_client: AiohttpClient, ++) -> None: ++ async def handler(request: web.Request) -> NoReturn: ++ await request.post() ++ assert False ++ ++ app = web.Application(client_max_size=1) ++ app.router.add_post("/", handler) ++ client = await aiohttp_client(app) ++ ++ body = _multipart_file_parts(55) ++ assert len(body) > 1 ++ async with client.post( ++ "/", data=body, headers={CONTENT_TYPE: "multipart/form-data; boundary=b"} ++ ) as resp: ++ assert resp.status == 413 ++ ++ ++@pytest.mark.parametrize( ++ "max_size", ++ (_FILE_SPOOL_MAX_SIZE, 2 * _FILE_SPOOL_MAX_SIZE), ++ ids=("spooled", "rolled-over"), ++) ++async def test_post_max_client_size_within_single_part( ++ aiohttp_client: AiohttpClient, max_size: int ++) -> None: ++ async def handler(request: web.Request) -> NoReturn: ++ await request.post() ++ assert False ++ ++ app = web.Application(client_max_size=max_size) ++ app.router.add_post("/", handler) ++ client = await aiohttp_client(app) ++ ++ # A sole oversized part is never followed by a boundary, so only the ++ # per-chunk check can reject it, and it must do so before buffering. The ++ # larger limit lets the part reach the disk first, so the spool is then ++ # closed off the event loop too. ++ data = FormData() ++ with io.BytesIO(b"x" * (3 * _FILE_SPOOL_MAX_SIZE)) as file_handle: ++ data.add_field("file", file_handle, filename="x.bin") ++ async with client.post("/", data=data) as resp: ++ assert resp.status == 413 ++ ++ ++@pytest.mark.parametrize("filename", (b"", b'; filename="x"'), ids=("field", "file")) ++async def test_post_max_client_size_counts_undecoded_bytes( ++ aiohttp_client: AiohttpClient, filename: bytes ++) -> None: ++ async def handler(request: web.Request) -> NoReturn: ++ await request.post() ++ assert False ++ ++ limit = 64 * 1024 ++ app = web.Application(client_max_size=limit) ++ app.router.add_post("/", handler) ++ client = await aiohttp_client(app) ++ ++ # base64 decodes to exactly the limit but arrives 4/3 larger. The limit is ++ # on the request body, so both kinds of part must reject it alike. ++ encoded = base64.b64encode(b"A" * limit) ++ assert len(encoded) > limit ++ body = ( ++ b'--b\r\nContent-Disposition: form-data; name="f"' + filename + b"\r\n" ++ b"Content-Transfer-Encoding: base64\r\n\r\n" + encoded + b"\r\n--b--\r\n" ++ ) ++ async with client.post( ++ "/", data=body, headers={CONTENT_TYPE: "multipart/form-data; boundary=b"} ++ ) as resp: ++ assert resp.status == 413 ++ ++ ++@pytest.mark.skipif(not os.path.isdir("/dev/fd"), reason="needs /dev/fd to count fds") ++@pytest.mark.parametrize( ++ ("parts", "part_body", "expected_fds"), ++ ( ++ (55, b"payload", 0), ++ (1, b"x" * (_FILE_SPOOL_MAX_SIZE + 1), 1), ++ ), ++ ids=("many-small-parts", "one-oversized-part"), ++) ++async def test_post_file_fields_descriptor_cost( ++ aiohttp_client: AiohttpClient, parts: int, part_body: bytes, expected_fds: int ++) -> None: ++ """Only a part past the spool size may cost a descriptor.""" ++ ++ async def handler(request: web.Request) -> web.Response: ++ before = set(await asyncio.to_thread(os.listdir, "/dev/fd")) ++ data = await request.post() ++ after = set(await asyncio.to_thread(os.listdir, "/dev/fd")) ++ assert len(data) == parts ++ return web.Response(text=str(len(after - before))) ++ ++ app = web.Application(client_max_size=8 * 1024**2) ++ app.router.add_post("/", handler) ++ client = await aiohttp_client(app) ++ ++ with io.BytesIO(_multipart_file_parts(parts, part_body)) as body: ++ async with client.post( ++ "/", ++ data=body, ++ headers={CONTENT_TYPE: "multipart/form-data; boundary=b"}, ++ ) as resp: ++ assert resp.status == 200 ++ assert await resp.text() == str(expected_fds) ++ ++ ++@pytest.mark.parametrize( ++ "size", ++ ( ++ 0, ++ 1, ++ _FILE_SPOOL_MAX_SIZE - 1, ++ _FILE_SPOOL_MAX_SIZE, ++ _FILE_SPOOL_MAX_SIZE + 1, ++ ), ++) ++async def test_post_file_field_spool_rollover( ++ aiohttp_client: AiohttpClient, size: int ++) -> None: ++ """A part is byte-identical either side of the spool/temp-file boundary.""" ++ payload = (b"0123456789abcdef" * (size // 16 + 1))[:size] ++ ++ async def handler(request: web.Request) -> web.Response: ++ field = (await request.post())["file"] ++ assert isinstance(field, aiohttp.web_request.FileField) ++ assert isinstance(field.file, io.IOBase) ++ assert field.file.readable() ++ assert field.file.writable() ++ assert field.file.seekable() ++ content = await asyncio.to_thread(field.file.read) ++ assert len(content) == size ++ assert content == payload ++ return web.Response() ++ ++ app = web.Application(client_max_size=8 * 1024**2) ++ app.router.add_post("/", handler) ++ client = await aiohttp_client(app) ++ ++ data = FormData() ++ with io.BytesIO(payload) as file_handle: ++ data.add_field("file", file_handle, filename="x.bin") ++ async with client.post("/", data=data) as resp: ++ assert resp.status == 200 ++ ++ + async def test_response_with_bodypart(aiohttp_client) -> None: + async def handler(request): + reader = await request.multipart() diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p2.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p2.patch new file mode 100644 index 0000000000..9b9345f577 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111066_p2.patch @@ -0,0 +1,57 @@ +From 011c08204197beba8281911907b59ffcecea1413 Mon Sep 17 00:00:00 2001 +From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> +Date: Wed, 26 Aug 2026 02:39:52 +0100 +Subject: [PATCH] [PR #13549/4602990f backport][3.14] Fix flaky descriptor test + (#13551) + +**This is a backport of PR #13549 as merged into master +(4602990f60241f759f861a3a4c1243ba2294ab53).** + +Co-authored-by: Sam Bull + +CVE: CVE-2026-111066 +Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/011c08204197beba8281911907b59ffcecea1413] + +Signed-off-by: Darsh Kelaiya +--- + tests/test_web_functional.py | 20 +++++++++++++++++--- + 1 file changed, 17 insertions(+), 3 deletions(-) + +diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py +index a314d9b..9fafd52 100644 +--- a/tests/test_web_functional.py ++++ b/tests/test_web_functional.py +@@ -2135,7 +2135,9 @@ async def test_post_max_client_size_counts_undecoded_bytes( + assert resp.status == 413 + + +-@pytest.mark.skipif(not os.path.isdir("/dev/fd"), reason="needs /dev/fd to count fds") ++@pytest.mark.skipif( ++ not os.path.isdir("/proc/self/fd"), reason="needs /proc/self/fd to identify fds" ++) + @pytest.mark.parametrize( + ("parts", "part_body", "expected_fds"), + ( +@@ -2150,8 +2152,20 @@ async def test_post_file_fields_descriptor_cost( + """Only a part past the spool size may cost a descriptor.""" + ++ def deleted_file_fds() -> set[tuple[str, str]]: ++ """(fd, symlink target) pairs for open fds backed by deleted files.""" ++ fds = set() ++ for name in os.listdir("/proc/self/fd"): ++ try: ++ target = os.readlink(f"/proc/self/fd/{name}") ++ except OSError: ++ continue # closed between listdir and readlink ++ if target.endswith("(deleted)"): ++ fds.add((name, target)) ++ return fds ++ + async def handler(request: web.Request) -> web.Response: +- before = set(await asyncio.to_thread(os.listdir, "/dev/fd")) ++ before = await asyncio.to_thread(deleted_file_fds) + data = await request.post() +- after = set(await asyncio.to_thread(os.listdir, "/dev/fd")) ++ after = await asyncio.to_thread(deleted_file_fds) + assert len(data) == parts + return web.Response(text=str(len(after - before))) 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 bd5b5fe4d9..917405c715 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 @@ -22,6 +22,8 @@ SRC_URI += " \ file://CVE-2026-59881.patch \ file://CVE-2026-69243.patch \ file://CVE-2026-69244.patch \ + file://CVE-2026-111066_p1.patch \ + file://CVE-2026-111066_p2.patch \ " CVE_PRODUCT = "aiohttp:aiohttp"