python3-aiohttp: fix CVE-2026-111066

Backport the multipart descriptor-exhaustion fix [1] to aiohttp
3.13.5. Spool small file parts in memory and count raw multipart
bytes, including headers, against client_max_size.

Include the upstream descriptor-test correction [2] so unrelated
file descriptors do not make the regression test unreliable.
Adapt the HTTP 413 constructor and retain the existing read size.
The vulnerability and fixed release are documented in [3].

[1] https://github.com/aio-libs/aiohttp/commit/2d94633b7f1b4c1ba07d647b629a8440652b2d09
[2] https://github.com/aio-libs/aiohttp/commit/011c08204197beba8281911907b59ffcecea1413
[3] https://github.com/aio-libs/aiohttp/security/advisories/GHSA-9xw5-w5jj-pvcg

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Darsh Kelaiya authored and Anuj Mittal committed 2026-11-09 09:10:47 +05:30
1 parent 45251809f1
commit ba8d0af836
3 files changed
+459

No files matched your search

@@ -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()
@@ -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"