From b05e3498d7b7b48f7b3a512a638fabea4ca4cd9f Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 3 Jan 2026 04:53:29 +0000 Subject: [PATCH] Replace asserts with exceptions (#11897) (#11914) CVE: CVE-2025-69227 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/bc1319ec3cbff9438a758951a30907b072561259] Backport Changes: - Adapted the _read_chunk_from_stream() EOF check to aiohttp 3.9.5, where _content_eof is updated outside the newer upstream read loop. - Imported CIMultiDict and CIMultiDictProxy explicitly because aiohttp 3.9.5's test_multipart module does not import them. (cherry picked from commit d5bf65f15c0c718b6b95e9bc9d0914a92c51e60f) Co-authored-by: J. Nick Koston (cherry picked from commit bc1319ec3cbff9438a758951a30907b072561259) Signed-off-by: Darsh Kelaiya --- aiohttp/multipart.py | 10 ++++------ aiohttp/web_request.py | 8 +++----- tests/test_multipart.py | 13 ++++++++++++- tests/test_web_request.py | 24 +++++++++++++++++++++++- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 520ee539e..9e5ff9b41 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -325,11 +325,8 @@ class BodyPartReader: self._read_bytes += len(chunk) if self._read_bytes == self._length: self._at_eof = True - if self._at_eof: - clrf = await self._content.readline() - assert ( - b"\r\n" == clrf - ), "reader did not read all the data or it is malformed" + if self._at_eof and await self._content.readline() != b"\r\n": + raise ValueError("Reader did not read all the data or it is malformed") return chunk async def _read_chunk_from_length(self, size: int) -> bytes: @@ -354,7 +351,8 @@ class BodyPartReader: chunk = await self._content.read(size) self._content_eof += int(self._content.at_eof()) - assert self._content_eof < 3, "Reading after EOF" + if self._content_eof > 2: + raise ValueError("Reading after EOF") assert self._prev_chunk is not None window = self._prev_chunk + chunk sub = b"\r\n" + self._boundary diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index b3d614186..cd77b7bde 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -713,12 +713,12 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): max_size = self._client_max_size size = 0 - field = await multipart.next() - while field is not None: + while (field := await multipart.next()) is not None: field_ct = field.headers.get(hdrs.CONTENT_TYPE) if isinstance(field, BodyPartReader): - assert field.name is not None + if field.name is None: + raise ValueError("Multipart field missing name.") # Note that according to RFC 7578, the Content-Type header # is optional, even for files, so we can't assume it's @@ -770,8 +770,6 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): raise ValueError( "To decode nested multipart you need " "to use custom reader", ) - - field = await multipart.next() else: data = await self.read() if data: diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 436b70957..e46eb29bd5 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -6,6 +6,7 @@ from unittest import mock import pytest +from multidict import CIMultiDict, CIMultiDictProxy import aiohttp from aiohttp import payload @@ -200,11 +200,21 @@ class TestPartReader: with Stream(data) as stream: obj = aiohttp.BodyPartReader(BOUNDARY, {}, stream) result = b"" - with pytest.raises(AssertionError): + with pytest.raises(ValueError): for _ in range(4): result += await obj.read_chunk(7) assert data == result + async def test_read_with_content_length_malformed_crlf(self) -> None: + # Content-Length is correct but data after content is not \r\n + content = b"Hello" + h = CIMultiDictProxy(CIMultiDict({"CONTENT-LENGTH": str(len(content))})) + # Malformed: "XX" instead of "\r\n" after content + with Stream(content + b"XX--:--") as stream: + obj = aiohttp.BodyPartReader(BOUNDARY, h, stream) + with pytest.raises(ValueError, match="malformed"): + await obj.read() + async def test_read_boundary_with_incomplete_chunk(self) -> None: with Stream(b"") as stream: diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 704fc189a..962092999 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -10,6 +10,7 @@ from multidict import CIMultiDict, CIMultiDictProxy, MultiDict from yarl import URL from aiohttp import HttpVersion +from aiohttp.base_protocol import BaseProtocol from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request @@ -629,7 +630,28 @@ async def test_multipart_formdata(protocol) -> None: assert dict(result) == {"a": "b", "c": "d"} -async def test_multipart_formdata_file(protocol) -> None: +async def test_multipart_formdata_field_missing_name(protocol: BaseProtocol) -> None: + # Ensure ValueError is raised when Content-Disposition has no name + payload = StreamReader(protocol, 2**16, loop=asyncio.get_event_loop()) + payload.feed_data( + b"-----------------------------326931944431359\r\n" + b"Content-Disposition: form-data\r\n" # Missing name! + b"\r\n" + b"value\r\n" + b"-----------------------------326931944431359--\r\n" + ) + content_type = ( + "multipart/form-data; boundary=---------------------------326931944431359" + ) + payload.feed_eof() + req = make_mocked_request( + "POST", "/", headers={"CONTENT-TYPE": content_type}, payload=payload + ) + with pytest.raises(ValueError, match="Multipart field missing name"): + await req.post() + + +async def test_multipart_formdata_file(protocol: BaseProtocol) -> None: # Make sure file uploads work, even without a content type payload = StreamReader(protocol, 2**16, loop=asyncio.get_event_loop()) payload.feed_data( -- 2.44.4