python3-tornado: Fix CVE-2026-115927

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>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Yogita Urade authored and Anuj Mittal committed 2026-11-09 08:44:42 +05:30
1 parent d9af8ff1b7
commit 2248ece66b
2 files changed
+398

No files matched your search

@@ -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