new file mode 100644
@@ -0,0 +1,382 @@
+From a45985a1dba1aaacad2b12724b450496a6ab0903 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)
+(cherry picked from commit 2d94633b7f1b4c1ba07d647b629a8440652b2d09)
+
+CVE: CVE-2026-111066
+Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/2d94633b7f1b4c1ba07d647b629a8440652b2d09]
+
+Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
+---
+ CHANGES/13426.bugfix.rst | 1 +
+ aiohttp/web_request.py | 91 ++++++++++++++-----
+ tests/test_web_functional.py | 166 ++++++++++++++++++++++++++++++++++-
+ 3 files changed, 234 insertions(+), 24 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 000000000..8b9ee295f
+--- /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 ca925e2ac..eaed708eb 100644
+--- a/aiohttp/web_request.py
++++ b/aiohttp/web_request.py
+@@ -3,13 +3,13 @@ import datetime
+ import io
+ import re
+ import string
+-import tempfile
++import sys
+ import types
+ import warnings
+ from collections.abc import Iterator, Mapping, MutableMapping
+ from re import Pattern
+ from types import MappingProxyType
+-from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar, cast, overload
++from typing import IO, TYPE_CHECKING, Any, Final, Optional, TypeVar, cast, overload
+ from urllib.parse import parse_qsl
+
+ import attr
+@@ -54,6 +54,30 @@ from .typedefs import (
+ from .web_exceptions import HTTPRequestEntityTooLarge, NotAppKeyWarning
+ 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")
+
+
+@@ -66,11 +90,18 @@ if TYPE_CHECKING:
+ _T = TypeVar("_T")
+
+
++# 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]
+
+@@ -754,8 +785,13 @@ class BaseRequest(MutableMapping[str | RequestKey[Any], 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)
++
+ field_ct = field.headers.get(hdrs.CONTENT_TYPE)
+
+ if isinstance(field, BodyPartReader):
+@@ -767,20 +803,35 @@ class BaseRequest(MutableMapping[str | RequestKey[Any], 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=DEFAULT_CHUNK_SIZE):
++ # 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)
+ 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)
+- await self._loop.run_in_executor(None, tmp.seek, 0)
++ if rolled:
++ await self._loop.run_in_executor(
++ None, tmp.write, decoded_chunk
++ )
++ 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"
+@@ -788,7 +839,7 @@ class BaseRequest(MutableMapping[str | RequestKey[Any], Any], HeadersMixin):
+ ff = FileField(
+ field.name,
+ field.filename,
+- cast(io.BufferedReader, tmp),
++ tmp,
+ field_ct,
+ field.headers,
+ )
+@@ -797,8 +848,7 @@ class BaseRequest(MutableMapping[str | RequestKey[Any], 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)
+ raw_data.extend(chunk)
+
+@@ -865,10 +915,7 @@ class BaseRequest(MutableMapping[str | RequestKey[Any], 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 d126347aa..78304a72c 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
+@@ -31,6 +33,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
+@@ -2072,8 +2075,167 @@ async def test_post_max_client_size_for_file(aiohttp_client) -> None:
+ await resp.release()
+
+
+-async def test_response_with_bodypart(aiohttp_client) -> None:
+- async def handler(request):
++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: AiohttpClient) -> None:
++ async def handler(request: web.Request) -> web.Response:
+ reader = await request.multipart()
+ part = await reader.next()
+ return web.Response(body=part)
@@ -8,6 +8,7 @@ SRC_URI[sha256sum] = "9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f
SRC_URI += "file://CVE-2026-111063.patch \
file://CVE-2026-111065.patch \
+ file://CVE-2026-111066.patch \
"
CVE_PRODUCT = "aiohttp:aiohttp"