mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-14 03:57:15 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user