diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-115927.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-115927.patch new file mode 100644 index 0000000000..ffefe9db15 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-115927.patch @@ -0,0 +1,397 @@ +From ceab33581489751a66fba8566ad6d5960d4b01c8 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Wed, 7 Oct 2026 15:48:00 +0000 +Subject: [PATCH] http1connection: Avoid event loop starvation from chunked + bodies + +Reads of body data that has already arrived complete without returning +control to the IOLoop, so a client sending a chunked body made of many +tiny chunks (which cost ~6us each to parse) could block the event loop +for the entire body (minutes, with the default max_body_size). + +- Yield to the IOLoop periodically while reading any body (every 256KB, + with each chunk of a chunked body counting as 1KB). +- Coalesce consecutive small chunks before calling data_received. Pending + data is flushed whenever we would wait for the network, so streaming + latency is unaffected. +- Accumulate non-streaming bodies in a bytearray instead of a list of + bytes objects, which used ~120 bytes of memory per 1-byte chunk. +- Raise HTTPInputError instead of using assert for a chunk that is not + followed by CRLF. +- Document the baseline for denial-of-service reports in SECURITY.md. + +CVE: CVE-2026-115927 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/ceab33581489751a66fba8566ad6d5960d4b01c8] + +Backport Changes: +- Kept the 6.5.7 read_until_close() path for close-delimited bodies because + the newer incremental reader is absent; the affected chunked path and + fixed-length reads retain the upstream yielding behavior. +- Kept AsyncTestCase imported from tornado.testing to match the 6.5.7 test harness. +- Omitted SECURITY.md and docs/releases/v6.6.0.rst because they are documentation-only. + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_019XKZM6XMVNYGibRyDU6QZW +Signed-off-by: Yogita Urade +--- + tornado/http1connection.py | 97 +++++++++++++++++++++++----- + tornado/httpserver.py | 6 +- + tornado/simple_httpclient.py | 6 +++--- + tornado/test/http1connection_test.py | 59 ++++++++++++++++- + tornado/test/httpserver_test.py | 7 +- + tornado/test/web_test.py | 5 +- + tornado/web.py | 6 +- + 7 files changed, 156 insertions(+), 30 deletions(-) + +diff --git a/tornado/http1connection.py b/tornado/http1connection.py +index 3eb1dfc2..d4bb2cc6 100644 +--- a/tornado/http1connection.py ++++ b/tornado/http1connection.py +@@ -41,6 +41,23 @@ from typing import cast, Optional, Type, Awaitable, Callable, Union, Tuple + CR_OR_LF_RE = re.compile(b"\r|\n") + + ++# Reads that can be satisfied from data that has already arrived complete ++# without returning control to the IOLoop, so a peer that sends a body ++# faster than we can process it could otherwise monopolize the IOLoop for ++# the duration of the body. While reading a body, we yield to the IOLoop ++# after processing this many bytes. Each chunk of a chunked body also counts ++# as _CHUNK_YIELD_COST bytes, since the per-chunk overhead dominates when ++# the chunks are small. ++_YIELD_THRESHOLD = 256 * 1024 ++_CHUNK_YIELD_COST = 1024 ++ ++ ++def _is_done(future: Awaitable) -> bool: ++ # IOStream's read methods are declared to return Awaitables, but they ++ # are actually Futures which may already be resolved. ++ return isinstance(future, Future) and future.done() ++ ++ + class _QuietException(Exception): + def __init__(self) -> None: + pass +@@ -645,53 +662,101 @@ class HTTP1Connection(httputil.HTTPConnection): + return self._read_body_until_close(delegate) + return None + ++ async def _deliver_body( ++ self, delegate: httputil.HTTPMessageDelegate, data: bytes ++ ) -> None: ++ if not self._write_finished or self.is_client: ++ with _ExceptionLoggingContext(app_log): ++ ret = delegate.data_received(data) ++ if ret is not None: ++ await ret ++ + async def _read_fixed_body( + self, content_length: int, delegate: httputil.HTTPMessageDelegate + ) -> None: ++ work = 0 # See _YIELD_THRESHOLD + while content_length > 0: + body = await self.stream.read_bytes( + min(self.params.chunk_size, content_length), partial=True + ) + content_length -= len(body) +- if not self._write_finished or self.is_client: +- with _ExceptionLoggingContext(app_log): +- ret = delegate.data_received(body) +- if ret is not None: +- await ret ++ await self._deliver_body(delegate, body) ++ work += len(body) ++ if work >= _YIELD_THRESHOLD: ++ work = 0 ++ await asyncio.sleep(0) + + async def _read_chunked_body(self, delegate: httputil.HTTPMessageDelegate) -> None: + # TODO: "chunk extensions" http://tools.ietf.org/html/rfc2616#section-3.6.1 + total_size = 0 ++ # Chunk boundaries are not meaningful (and may be changed by ++ # intermediaries), so the contents of consecutive small chunks are ++ # coalesced before being passed to the delegate. This bounds the ++ # number of data_received calls (and the overhead of delegates that ++ # buffer each piece they receive) regardless of how small the chunks ++ # are. Pending data is delivered whenever we would have to wait for ++ # the network, so this does not add latency for streaming delegates. ++ pending = bytearray() ++ work = 0 # See _YIELD_THRESHOLD ++ ++ def read(future: Awaitable[bytes]) -> Awaitable[bytes]: ++ if pending and not _is_done(future): ++ return flush_and_read(future) ++ return future ++ ++ async def flush_and_read(future: Awaitable[bytes]) -> bytes: ++ data = bytes(pending) ++ pending.clear() ++ await self._deliver_body(delegate, data) ++ return await future ++ + while True: +- chunk_len_str = await self.stream.read_until(b"\r\n", max_bytes=64) ++ work += _CHUNK_YIELD_COST ++ if work >= _YIELD_THRESHOLD: ++ work = 0 ++ await asyncio.sleep(0) ++ chunk_len_str = await read(self.stream.read_until(b"\r\n", max_bytes=64)) + try: + chunk_len = parse_hex_int(native_str(chunk_len_str[:-2])) + except ValueError: + raise httputil.HTTPInputError("invalid chunk size") + if chunk_len == 0: +- crlf = await self.stream.read_bytes(2) ++ crlf = await read(self.stream.read_bytes(2)) + if crlf != b"\r\n": + raise httputil.HTTPInputError( + "improperly terminated chunked request" + ) ++ if pending: ++ await self._deliver_body(delegate, bytes(pending)) + return + total_size += chunk_len + if total_size > self._max_body_size: + raise httputil.HTTPInputError("chunked body too large") + bytes_to_read = chunk_len + while bytes_to_read: +- chunk = await self.stream.read_bytes( +- min(bytes_to_read, self.params.chunk_size), partial=True ++ chunk = await read( ++ self.stream.read_bytes( ++ min(bytes_to_read, self.params.chunk_size), partial=True ++ ) + ) + bytes_to_read -= len(chunk) +- if not self._write_finished or self.is_client: +- with _ExceptionLoggingContext(app_log): +- ret = delegate.data_received(chunk) +- if ret is not None: +- await ret ++ if not pending and len(chunk) == self.params.chunk_size: ++ # Full-sized reads are passed through without copying. ++ await self._deliver_body(delegate, chunk) ++ else: ++ pending += chunk ++ if len(pending) >= self.params.chunk_size: ++ data = bytes(pending[: self.params.chunk_size]) ++ del pending[: self.params.chunk_size] ++ await self._deliver_body(delegate, data) ++ work += len(chunk) ++ if work >= _YIELD_THRESHOLD: ++ work = 0 ++ await asyncio.sleep(0) + # chunk ends with \r\n +- crlf = await self.stream.read_bytes(2) +- assert crlf == b"\r\n" ++ crlf = await read(self.stream.read_bytes(2)) ++ if crlf != b"\r\n": ++ raise httputil.HTTPInputError("improperly terminated chunk") + + async def _read_body_until_close( + self, delegate: httputil.HTTPMessageDelegate +diff --git a/tornado/httpserver.py b/tornado/httpserver.py +index 68c6301b..ab5bea60 100644 +--- a/tornado/httpserver.py ++++ b/tornado/httpserver.py +@@ -267,7 +267,7 @@ class _CallableAdapter(httputil.HTTPMessageDelegate): + self.request_callback = request_callback + self.request = None # type: Optional[httputil.HTTPServerRequest] + self.delegate = None +- self._chunks = [] # type: List[bytes] ++ self._chunks = bytearray() + + def headers_received( + self, +@@ -282,12 +282,12 @@ class _CallableAdapter(httputil.HTTPMessageDelegate): + return None + + def data_received(self, chunk: bytes) -> Optional[Awaitable[None]]: +- self._chunks.append(chunk) ++ self._chunks += chunk + return None + + def finish(self) -> None: + assert self.request is not None +- self.request.body = b"".join(self._chunks) ++ self.request.body = bytes(self._chunks) + self.request._parse_body() + self.request_callback(self.request) + +diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py +index 9d693a44..df13e919 100644 +--- a/tornado/simple_httpclient.py ++++ b/tornado/simple_httpclient.py +@@ -276,7 +276,7 @@ class _HTTPConnection(httputil.HTTPMessageDelegate): + self.max_body_size = max_body_size + self.code = None # type: Optional[int] + self.headers = None # type: Optional[httputil.HTTPHeaders] +- self.chunks = [] # type: List[bytes] ++ self.chunks = bytearray() + self._decompressor = None + # Timeout handle returned by IOLoop.add_timeout + self._timeout = None # type: object +@@ -621,7 +621,7 @@ class _HTTPConnection(httputil.HTTPMessageDelegate): + + def finish(self) -> None: + assert self.code is not None +- data = b"".join(self.chunks) ++ data = bytes(self.chunks) + self._remove_timeout() + original_request = getattr(self.request, "original_request", self.request) + if self._should_follow_redirect(): +@@ -730,7 +730,7 @@ class _HTTPConnection(httputil.HTTPMessageDelegate): + if self.request.streaming_callback is not None: + self.request.streaming_callback(chunk) + else: +- self.chunks.append(chunk) ++ self.chunks += chunk + + + if __name__ == "__main__": +diff --git a/tornado/test/http1connection_test.py b/tornado/test/http1connection_test.py +index 34de6d38..7443ae1c 100644 +--- a/tornado/test/http1connection_test.py ++++ b/tornado/test/http1connection_test.py +@@ -1,12 +1,15 @@ ++import logging + import socket + import typing # noqa(F401) + ++from tornado import gen + from tornado.http1connection import HTTP1Connection + from tornado.httputil import HTTPMessageDelegate + from tornado.iostream import IOStream + from tornado.locks import Event + from tornado.netutil import add_accept_handler +-from tornado.testing import AsyncTestCase, bind_unused_port, gen_test ++from tornado.log import gen_log ++from tornado.testing import AsyncTestCase, ExpectLog, bind_unused_port, gen_test + + + class HTTP1ConnectionTest(AsyncTestCase): +@@ -59,3 +62,57 @@ class HTTP1ConnectionTest(AsyncTestCase): + yield event.wait() + self.assertEqual(self.code, 200) + self.assertEqual(b"".join(body), b"hello") ++ ++ @gen_test ++ def test_chunked_body_tiny_chunks(self): ++ # A body made of many tiny chunks is coalesced into a few ++ # data_received calls, and reading it yields to the IOLoop ++ # periodically even though all of the data is already available. ++ conn = HTTP1Connection(self.client_stream, True) ++ num_chunks = 5000 ++ yield self.server_stream.write( ++ b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++ + b"1\r\nx\r\n" * num_chunks ++ + b"0\r\n\r\n" ++ ) ++ self.server_stream.close() ++ # Give the data time to arrive so that the body can be read ++ # without waiting for the network. ++ yield gen.sleep(0.1) ++ ++ body = [] ++ ticks = [0] ++ done = [False] ++ io_loop = self.io_loop ++ ++ # Count IOLoop iterations while the body is being read. ++ def tick(): ++ if not done[0]: ++ ticks[0] += 1 ++ io_loop.add_callback(tick) ++ ++ class Delegate(HTTPMessageDelegate): ++ def headers_received(self, start_line, headers): ++ io_loop.add_callback(tick) ++ ++ def data_received(self, data): ++ body.append(data) ++ ++ def finish(self): ++ done[0] = True ++ ++ yield conn.read_response(Delegate()) ++ self.assertEqual(b"".join(body), b"x" * num_chunks) ++ self.assertLess(len(body), 10) ++ self.assertGreater(ticks[0], 10) ++ ++ @gen_test ++ def test_chunked_body_bad_chunk_terminator(self): ++ conn = HTTP1Connection(self.client_stream, True) ++ self.server_stream.write( ++ b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++ b"3\r\nfooXX0\r\n\r\n" ++ ) ++ with ExpectLog(gen_log, ".*improperly terminated chunk", level=logging.INFO): ++ result = yield conn.read_response(HTTPMessageDelegate()) ++ self.assertIs(result, False) +diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py +index b3f6ed18..6e9d4d3a 100644 +--- a/tornado/test/httpserver_test.py ++++ b/tornado/test/httpserver_test.py +@@ -1247,9 +1247,10 @@ class StreamingChunkSizeTest(AsyncHTTPTestCase): + write(self.BODY[:20]) + write(self.BODY[20:]) + +- chunks = self.fetch_chunk_sizes(body_producer=body_producer) +- # HTTP chunk boundaries translate to application-visible breaks +- self.assertEqual([16, 4, 16, 14], chunks) ++ # HTTP chunk boundaries are not preserved: small chunks may be ++ # coalesced, so the assertions in fetch_chunk_sizes are as specific ++ # as we can get. ++ self.fetch_chunk_sizes(body_producer=body_producer) + + def test_chunked_compressed(self): + compressed = self.compress(self.BODY) +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 8a014d22..3ecb3ef5 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -2736,7 +2736,10 @@ class BaseStreamingRequestFlowControlTest: + ) + + def test_flow_control_chunked_body(self: typing.Any): +- chunks = [b"abcd", b"efgh", b"ijkl"] ++ # Small chunks may be coalesced (when they arrive together), so use ++ # chunks of exactly chunk_size (except the last) to get a ++ # predictable sequence of calls. ++ chunks = [b"abcdefghij", b"klmnopqrst", b"uvwxyz"] + + @gen.coroutine + def body_producer(write): +diff --git a/tornado/web.py b/tornado/web.py +index 2c8a1e1d..d3257221 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -2468,7 +2468,7 @@ class _HandlerDelegate(httputil.HTTPMessageDelegate): + self.handler_kwargs = handler_kwargs or {} + self.path_args = path_args or [] + self.path_kwargs = path_kwargs or {} +- self.chunks = [] # type: List[bytes] ++ self.chunks = bytearray() + self.stream_request_body = _has_stream_request_body(self.handler_class) + + def headers_received( +@@ -2485,7 +2485,7 @@ class _HandlerDelegate(httputil.HTTPMessageDelegate): + if self.stream_request_body: + return self.handler.data_received(data) + else: +- self.chunks.append(data) ++ self.chunks += data + return None + + def finish(self) -> None: +@@ -2494,7 +2494,7 @@ class _HandlerDelegate(httputil.HTTPMessageDelegate): + else: + # Note that the body gets parsed in RequestHandler._execute so it can be in + # the right exception handler scope. +- self.request.body = b"".join(self.chunks) ++ self.request.body = bytes(self.chunks) + self.execute() + + def on_connection_close(self) -> None: +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-tornado_6.5.7.bb b/meta-python/recipes-devtools/python/python3-tornado_6.5.7.bb index a84503db51..1dd2ff3c32 100644 --- a/meta-python/recipes-devtools/python/python3-tornado_6.5.7.bb +++ b/meta-python/recipes-devtools/python/python3-tornado_6.5.7.bb @@ -15,6 +15,7 @@ SRC_URI += "file://CVE-2026-91990.patch \ file://CVE-2026-103262.patch \ file://CVE-2026-103263.patch \ file://CVE-2026-103263-dependent.patch \ + file://CVE-2026-115927.patch \ " inherit pypi python_setuptools_build_meta