new file mode 100644
@@ -0,0 +1,400 @@
+From 2d94633b7f1b4c1ba07d647b629a8440652b2d09 Mon Sep 17 00:00:00 2001
+From: Sam Bull <git@sambull.org>
+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 <dkelaiya@cisco.com>
+---
+ 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()
new file mode 100644
@@ -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 <git@sambull.org>
+
+CVE: CVE-2026-111066
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/011c08204197beba8281911907b59ffcecea1413]
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ 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)))
@@ -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"