From b119720e4e9c19b23f8589dae05b9a39b26995e1 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 10 Mar 2026 20:36:38 +0000 Subject: [PATCH] Restrict multipart header sizes (#12208) (#12228) CVE: CVE-2026-34516 Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/8a74257b3804c9aac0bf644af93070f68f6c5a6f] Backport Changes: - Retained aiohttp 3.9.5's Optional/Union annotations in MultipartReader because this version supports Python 3.8 and cannot use the upstream PEP 604 union syntax. - Omitted the test_read_boundary_across_chunks() readline() signature update because that test is not present in aiohttp 3.9.5; retained the applicable Stream test-helper update. - Applied the multipart header trimming change using rstrip(b"\r\n") for compatibility with the aiohttp 3.9.5 codebase. (cherry picked from commit 5fe9dfb64400a574f5ba3f2d3b49b7db47567c29) (cherry picked from commit 8a74257b3804c9aac0bf644af93070f68f6c5a6f) Signed-off-by: Darsh Kelaiya --- aiohttp/multipart.py | 27 +++++++++++++++++++--- aiohttp/streams.py | 16 +++++++----- aiohttp/test_utils.py | 3 +++ aiohttp/web_protocol.py | 7 ++++++ aiohttp/web_request.py | 7 +++++- tests/test_multipart.py | 3 ++- tests/test_streams.py | 9 ++++--- tests/test_web_request.py | 53 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 106 insertions(+), 17 deletions(-) diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 9e5ff9b41..d7b993218 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -37,6 +37,7 @@ from .hdrs import ( ) from .helpers import CHAR, TOKEN, parse_mimetype, reify from .http import HeadersParser +from .http_exceptions import BadHttpMessage from .payload import ( JsonPayload, LookupError, @@ -547,7 +548,14 @@ class MultipartReader: #: Body part reader class for non multipart/* content types. part_reader_cls = BodyPartReader - def __init__(self, headers: Mapping[str, str], content: StreamReader) -> None: + def __init__( + self, + headers: Mapping[str, str], + content: StreamReader, + *, + max_field_size: int = 8190, + max_headers: int = 128, + ) -> None: self._mimetype = parse_mimetype(headers[CONTENT_TYPE]) assert self._mimetype.type == "multipart", "multipart/* content type expected" if "boundary" not in self._mimetype.parameters: @@ -560,6 +568,8 @@ class MultipartReader: self._content = content self._default_charset: Optional[str] = None self._last_part: Optional[Union["MultipartReader", BodyPartReader]] = None + self._max_field_size = max_field_size + self._max_headers = max_headers self._at_eof = False self._at_bof = True self._unread: List[bytes] = [] @@ -661,7 +671,12 @@ class MultipartReader: if mimetype.type == "multipart": if self.multipart_reader_cls is None: return type(self)(headers, self._content) - return self.multipart_reader_cls(headers, self._content) + return self.multipart_reader_cls( + headers, + self._content, + max_field_size=self._max_field_size, + max_headers=self._max_headers, + ) else: return self.part_reader_cls( self._boundary, @@ -723,12 +738,14 @@ class MultipartReader: async def _read_headers(self) -> "CIMultiDictProxy[str]": lines = [] while True: - chunk = await self._content.readline() + chunk = await self._content.readline(max_line_length=self._max_field_size) - chunk = chunk.strip() + chunk = chunk.rstrip(b"\r\n") lines.append(chunk) if not chunk: break - parser = HeadersParser() + if len(lines) > self._max_headers: + raise BadHttpMessage("Too many headers received") + parser = HeadersParser(max_field_size=self._max_field_size) headers, raw_headers = parser.parse_headers(lines) return headers diff --git a/aiohttp/streams.py b/aiohttp/streams.py index 121528842..dffaf374b 100644 --- a/aiohttp/streams.py +++ b/aiohttp/streams.py @@ -21,6 +21,7 @@ from .helpers import ( set_exception, set_result, ) +from .http_exceptions import LineTooLong from .log import internal_logger __all__ = ( @@ -327,10 +328,12 @@ class StreamReader(AsyncStreamReaderMixin): finally: self._waiter = None - async def readline(self) -> bytes: - return await self.readuntil() + async def readline(self, *, max_line_length: Optional[int] = None) -> bytes: + return await self.readuntil(max_size=max_line_length) - async def readuntil(self, separator: bytes = b"\n") -> bytes: + async def readuntil( + self, separator: bytes = b"\n", *, max_size: Optional[int] = None + ) -> bytes: seplen = len(separator) if seplen == 0: raise ValueError("Separator should be at least one-byte string") @@ -341,6 +344,7 @@ class StreamReader(AsyncStreamReaderMixin): chunk = b"" chunk_size = 0 not_enough = True + max_size = max_size or self._high_water while not_enough: while self._buffer and not_enough: @@ -355,8 +359,8 @@ class StreamReader(AsyncStreamReaderMixin): if ichar: not_enough = False - if chunk_size > self._high_water: - raise ValueError("Chunk too big") + if chunk_size > max_size: + raise LineTooLong(chunk[:100] + b"...", max_size) if self._eof: break @@ -572,7 +576,7 @@ class EmptyStreamReader(StreamReader): # lgtm [py/missing-call-to-init] def feed_data(self, data: bytes, n: int = 0) -> None: pass - async def readline(self) -> bytes: + async def readline(self, *, max_line_length: Optional[int] = None) -> bytes: return b"" async def read(self, n: int = -1) -> bytes: diff --git a/aiohttp/test_utils.py b/aiohttp/test_utils.py index a36e85996..a12bb0505 100644 --- a/aiohttp/test_utils.py +++ b/aiohttp/test_utils.py @@ -638,6 +638,9 @@ def make_mocked_request( if protocol is sentinel: protocol = mock.Mock() + protocol.max_field_size = 8190 + protocol.max_line_length = 8190 + protocol.max_headers = 128 protocol.transport = transport if writer is sentinel: diff --git a/aiohttp/web_protocol.py b/aiohttp/web_protocol.py index a06503e0c..a73bb4364 100644 --- a/aiohttp/web_protocol.py +++ b/aiohttp/web_protocol.py @@ -136,6 +136,9 @@ class RequestHandler(BaseProtocol): KEEPALIVE_RESCHEDULE_DELAY = 1 __slots__ = ( + "max_field_size", + "max_headers", + "max_line_size", "_request_count", "_keepalive", "_manager", @@ -193,6 +196,10 @@ class RequestHandler(BaseProtocol): self._request_handler: Optional[_RequestHandler] = manager.request_handler self._request_factory: Optional[_RequestFactory] = manager.request_factory + self.max_line_size = max_line_size + self.max_headers = max_headers + self.max_field_size = max_field_size + self._tcp_keepalive = tcp_keepalive # placeholder to be replaced on keepalive timeout setup self._keepalive_time = 0.0 diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index cd77b7bde..2ec09565c 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -687,7 +687,12 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin): async def multipart(self) -> MultipartReader: """Return async iterator to process BODY as multipart.""" - return MultipartReader(self._headers, self._payload) + return MultipartReader( + self._headers, + self._payload, + max_field_size=self._protocol.max_field_size, + max_headers=self._protocol.max_headers, + ) async def post(self) -> "MultiDictProxy[Union[str, bytes, FileField]]": """Return POST parameters.""" diff --git a/tests/test_multipart.py b/tests/test_multipart.py index e4a2be1f3..3bc9efd71 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -3,6 +3,7 @@ import io import json import pathlib import zlib +from typing import Optional from unittest import mock import pytest @@ -63,7 +64,7 @@ class Stream: def at_eof(self): return self.content.tell() == len(self.content.getbuffer()) - async def readline(self): + async def readline(self, *, max_line_length: Optional[int] = None) -> bytes: return self.content.readline() def unread_data(self, data): diff --git a/tests/test_streams.py b/tests/test_streams.py index ed65b567a..2076bc9ba 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -12,6 +12,7 @@ import pytest from re_assert import Matches from aiohttp import streams +from aiohttp.http_exceptions import LineTooLong DATA = b"line1\nline2\nline3\n" @@ -325,7 +326,7 @@ class TestStreamReader: stream.feed_data(b"li") stream.feed_data(b"ne1\nline2\n") - with pytest.raises(ValueError): + with pytest.raises(LineTooLong): await stream.readline() # The buffer should contain the remaining data after exception stream.feed_eof() @@ -346,7 +347,7 @@ class TestStreamReader: loop.call_soon(cb) - with pytest.raises(ValueError): + with pytest.raises(LineTooLong): await stream.readline() data = await stream.read() assert b"chunk3\n" == data @@ -436,7 +437,7 @@ class TestStreamReader: stream.feed_data(b"li") stream.feed_data(b"ne1" + separator + b"line2" + separator) - with pytest.raises(ValueError): + with pytest.raises(LineTooLong): await stream.readuntil(separator) # The buffer should contain the remaining data after exception stream.feed_eof() @@ -458,7 +459,7 @@ class TestStreamReader: loop.call_soon(cb) - with pytest.raises(ValueError, match="Chunk too big"): + with pytest.raises(LineTooLong): await stream.readuntil(separator) data = await stream.read() assert b"chunk3#" == data diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 962092999..c1d61f895 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -11,6 +11,7 @@ from yarl import URL from aiohttp import HttpVersion from aiohttp.base_protocol import BaseProtocol +from aiohttp.http_exceptions import BadHttpMessage, LineTooLong from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request @@ -676,7 +677,57 @@ async def test_multipart_formdata_file(protocol: BaseProtocol) -> None: result["a_file"].file.close() -async def test_make_too_big_request_limit_None(protocol) -> None: +async def test_multipart_formdata_headers_too_many(protocol: BaseProtocol) -> None: + many = b"".join(f"X-{i}: a\r\n".encode() for i in range(130)) + body = ( + b"--b\r\n" + b'Content-Disposition: form-data; name="a"\r\n' + many + b"\r\n1\r\n" + b"--b--\r\n" + ) + content_type = "multipart/form-data; boundary=b" + payload = StreamReader(protocol, 2**16, loop=asyncio.get_running_loop()) + payload.feed_data(body) + payload.feed_eof() + req = make_mocked_request( + "POST", + "/", + headers={"CONTENT-TYPE": content_type}, + payload=payload, + ) + + with pytest.raises(BadHttpMessage, match="Too many headers received"): + await req.post() + + +async def test_multipart_formdata_header_too_long(protocol: BaseProtocol) -> None: + k = b"t" * 4100 + body = ( + b"--b\r\n" + b'Content-Disposition: form-data; name="a"\r\n' + + k + + b":" + + k + + b"\r\n" + + b"\r\n1\r\n" + b"--b--\r\n" + ) + content_type = "multipart/form-data; boundary=b" + payload = StreamReader(protocol, 2**16, loop=asyncio.get_running_loop()) + payload.feed_data(body) + payload.feed_eof() + req = make_mocked_request( + "POST", + "/", + headers={"CONTENT-TYPE": content_type}, + payload=payload, + ) + + match = "400, message:\n Got more than 8190 bytes when reading" + with pytest.raises(LineTooLong, match=match): + await req.post() + + +async def test_make_too_big_request_limit_None(protocol: BaseProtocol) -> None: payload = StreamReader(protocol, 2**16, loop=asyncio.get_event_loop()) large_file = 1024**2 * b"x" too_large_file = large_file + b"x" -- 2.35.6