new file mode 100644
@@ -0,0 +1,397 @@
+From ceab33581489751a66fba8566ad6d5960d4b01c8 Mon Sep 17 00:00:00 2001
+From: Claude <noreply@anthropic.com>
+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 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_019XKZM6XMVNYGibRyDU6QZW
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ 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
+
@@ -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
This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. [1] https://github.com/tornadoweb/tornado/commit/ceab33581489751a66fba8566ad6d5960d4b01c8 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-115927 Signed-off-by: Yogita Urade <yurade@cisco.com> --- .../python3-tornado/CVE-2026-115927.patch | 397 ++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 1 + 2 files changed, 398 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-115927.patch