diff mbox series

[meta-oe,wrynose,4/6] python3-tornado: Fix CVE-2026-115927

Message ID 20261105070819.2086816-5-yurade@cisco.com
State New
Headers show
Series python3-tornado: Security fixes | expand

Commit Message

Yogita Urade Nov. 5, 2026, 7:08 a.m. UTC
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
diff mbox series

Patch

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