mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-14 03:57:15 +00:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user