new file mode 100644
@@ -0,0 +1,1117 @@
+From e412435febba4569c552c5c9054f1bf92bd171a9 Mon Sep 17 00:00:00 2001
+From: Claude <noreply@anthropic.com>
+Date: Sat, 12 Sep 2026 21:17:45 +0000
+Subject: [PATCH] Bound the memory an HTTP client response can consume
+
+Backport for 6.5.9. On master this is ten commits; the net change is the
+same, minus two things that do not exist on this branch: master's rejection
+of a coroutine streaming_callback (here streaming_callback is typed
+Callable[[bytes], None], and simple_httpclient already calls it
+synchronously, so calling it directly is what the contract says), and
+master's newer annotation syntax.
+
+curl_httpclient passed every chunk libcurl produced to IOLoop.add_callback
+and returned immediately, so a response body accumulated in the IOLoop's
+callback queue instead of reaching the application as it arrived. With
+decompress_response, a "decompression bomb" turns a few kilobytes on the
+wire into gigabytes in that queue: a 4GiB bomb peaked at ~400MB of RSS,
+~350MB of it queued chunks. Chunks now go through _CurlStreamingBuffer,
+which pauses the transfer once more than 1MB is waiting and resumes it once
+the callback has caught up, holding the same fetch to ~58MB regardless of
+the response size.
+
+Without a streaming_callback the whole body is buffered, so a bomb cannot
+be retrieved at all -- only refused. simple_httpclient already refused one
+at max_body_size, but curl_httpclient had no limit and would buffer a
+520KB response expanding to 512MiB in full. CurlAsyncHTTPClient takes
+max_body_size, defaulting to the same 100MB, applied to streaming
+responses too so that the two implementations agree. Retrieving a response
+larger than that now means raising the limit deliberately.
+
+libcurl's CURLOPT_MAXFILESIZE would have been the place to delegate the
+limit, but outside of very recent versions it is measured against the
+compressed size, which a bomb keeps small.
+
+Also: unpausing only makes a transfer runnable again; libcurl has reported
+that through the timer callback since 7.69, and before that a paused
+transfer stalls until unrelated socket activity wakes it, so the buffer
+asks for a socket_action itself. The documented libcurl requirement moves
+to 7.81, the version in the oldest supported Ubuntu LTS, which is above
+both that and the 7.71 cap on libcurl's own pause buffer.
+
+Verified against libcurl 8.11.1, 8.16.0 and 8.21.0, and with black 24.4.2,
+flake8 7.0.0 and mypy 1.10.0 as pinned on this branch.
+
+CVE: CVE-2026-103262
+Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/e412435febba4569c552c5c9054f1bf92bd171a9]
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_01BD36EiRmzMSFqZeAqZu4gm
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ docs/httpclient.rst | 13 +-
+ docs/index.rst | 3 +-
+ tornado/curl_httpclient.py | 175 +++++++++++++-
+ tornado/test/circlerefs_test.py | 50 +++-
+ tornado/test/curl_httpclient_test.py | 226 +++++++++++++++++-
+ tornado/test/httpclient_test.py | 309 ++++++++++++++++++++++++-
+ tornado/test/simple_httpclient_test.py | 31 +--
+ 7 files changed, 759 insertions(+), 48 deletions(-)
+
+diff --git a/docs/httpclient.rst b/docs/httpclient.rst
+index 3837d4c4..39896442 100644
+--- a/docs/httpclient.rst
++++ b/docs/httpclient.rst
+@@ -53,7 +53,7 @@ Implementations
+
+ .. module:: tornado.curl_httpclient
+
+-.. class:: CurlAsyncHTTPClient(max_clients=10, defaults=None)
++.. class:: CurlAsyncHTTPClient(max_clients=10, defaults=None, max_body_size=104857600)
+
+ ``libcurl``-based HTTP client.
+
+@@ -67,6 +67,17 @@ Implementations
+ ``defaults`` is a dict of parameters that will be used as defaults on all
+ `.HTTPRequest` objects submitted to this client.
+
++ ``max_body_size`` (default 100MB) is the largest response body that the
++ client will accept, measured after decompression so that a compressed
++ response cannot expand past it. As in `.SimpleAsyncHTTPClient`, it applies
++ whether or not a ``streaming_callback`` is used; raise it to retrieve
++ responses larger than that.
++
++ .. versionchanged:: 6.5.9
++ Added the ``max_body_size`` argument. Previously a response body was
++ accepted without limit, so a server could exhaust the client's memory
++ with a compressed response.
++
+ Example Code
+ ~~~~~~~~~~~~
+
+diff --git a/docs/index.rst b/docs/index.rst
+index 9b121862..61b97a9c 100644
+--- a/docs/index.rst
++++ b/docs/index.rst
+@@ -103,7 +103,8 @@ source tarball or clone the `git repository
+ optional packages may be useful:
+
+ * `pycurl <http://pycurl.io/>`_ is used by the optional
+- ``tornado.curl_httpclient``. Libcurl version 7.22 or higher is required.
++ ``tornado.curl_httpclient``. Libcurl version 7.81 or higher is required
++ (the version shipped by Ubuntu 22.04 LTS).
+ * `pycares <https://pypi.org/project/pycares/>`_ is an alternative
+ non-blocking DNS resolver that can be used when threads are not
+ appropriate.
+diff --git a/tornado/curl_httpclient.py b/tornado/curl_httpclient.py
+index 8d680666..71a99c2a 100644
+--- a/tornado/curl_httpclient.py
++++ b/tornado/curl_httpclient.py
+@@ -37,7 +37,7 @@ from tornado.httpclient import (
+ )
+ from tornado.log import app_log
+
+-from typing import Dict, Any, Callable, Union, Optional
++from typing import Dict, Any, Callable, Optional
+ import typing
+
+ if typing.TYPE_CHECKING:
+@@ -48,11 +48,138 @@ curl_log = logging.getLogger("tornado.curl_httpclient")
+ CR_OR_LF_RE = re.compile(b"\r|\n")
+
+
++class _CurlStreamingBuffer:
++ """Delivers response body chunks from libcurl to a ``streaming_callback``.
++
++ libcurl runs its write callback from inside
++ ``curl_multi_socket_action``, where it is not safe to run application
++ code (which may reenter this client, or raise exceptions that libcurl
++ would turn into transfer errors). Chunks are therefore queued here and
++ delivered later from the `.IOLoop`.
++
++ Queuing alone would let the amount of buffered data grow with whatever
++ the server chooses to send, which is a problem when
++ ``decompress_response`` is in use: a "decompression bomb" lets a server
++ turn a few kilobytes on the wire into gigabytes of decompressed data,
++ all of which would pile up in the `.IOLoop`'s callback queue before any
++ of it reached the application. To bound this, the transfer is paused
++ once more than `max_buffer_size` bytes are waiting, and resumed after
++ the callback has consumed them.
++ """
++
++ # Pause the transfer while at least this many bytes are waiting to be
++ # delivered. This bounds our own queue only: a paused libcurl still
++ # finishes decoding the socket read it already has (there is no way to
++ # tell its decoder to stop) and buffers the result internally, so the
++ # real high-water mark is one read's worth of expansion -- roughly 16MB
++ # for gzip at libcurl's default CURLOPT_BUFFERSIZE. libcurl limits that
++ # buffer to 64MB and fails the transfer with CURLE_TOO_LARGE past it,
++ # which a codec with a higher ratio than deflate's (brotli, say) will
++ # reach. That is libcurl's call to make; we just avoid holding a copy.
++ max_buffer_size = 1024 * 1024
++
++ def __init__(
++ self,
++ client: "CurlAsyncHTTPClient",
++ curl: pycurl.Curl,
++ callback: Callable[[bytes], Any],
++ ) -> None:
++ self.client = client
++ self.io_loop = client.io_loop
++ # Set to None in finish(); the handle may be reused after that.
++ self.curl = curl # type: Optional[pycurl.Curl]
++ self.callback = callback
++ self.chunks = collections.deque() # type: Deque[bytes]
++ self.size = 0
++ self.total = 0
++ self.body_too_large = False
++ self.paused = False
++ self.flush_scheduled = False
++
++ def write(self, chunk: bytes) -> int:
++ """libcurl ``WRITEFUNCTION`` callback."""
++ if self.total + len(chunk) > self.client.max_body_size:
++ # max_body_size applies here as well as to a buffered response:
++ # it is a safeguard for applications that do not impose a limit
++ # of their own on what they do with the chunks. Writing less
++ # than we were given makes libcurl fail the transfer.
++ self.body_too_large = True
++ return 0
++ if self.size >= self.max_buffer_size:
++ # Tell libcurl to stop reading from the socket until flush()
++ # resumes it. This chunk is not consumed here: libcurl holds
++ # on to it and passes it to us again when we unpause.
++ self.paused = True
++ return pycurl.WRITEFUNC_PAUSE
++ self.chunks.append(chunk)
++ self.size += len(chunk)
++ self.total += len(chunk)
++ self._schedule_flush()
++ return len(chunk)
++
++ def _schedule_flush(self) -> None:
++ if not self.flush_scheduled:
++ self.flush_scheduled = True
++ self.io_loop.add_callback(self.flush)
++
++ def flush(self) -> None:
++ """Pass everything we have buffered to the ``streaming_callback``."""
++ self.flush_scheduled = False
++ try:
++ while self.chunks:
++ chunk = self.chunks.popleft()
++ self.size -= len(chunk)
++ self.callback(chunk)
++ finally:
++ if self.chunks:
++ # The callback raised; deliver the rest (and unpause) later.
++ self._schedule_flush()
++ elif self.paused:
++ self.paused = False
++ if self.curl is not None:
++ try:
++ # Note that this may call write() again
++ # (synchronously) with data libcurl buffered while
++ # paused, so it must come after our own buffer has
++ # been drained.
++ self.curl.pause(pycurl.PAUSE_CONT)
++ except pycurl.error:
++ # The transfer failed while it was paused (libcurl
++ # reports the pending error here). Nothing to do:
++ # _finish will pass the error to the callback.
++ pass
++ else:
++ # Ask for a socket_action so the unpaused transfer
++ # makes progress. libcurl has notified us of this
++ # itself (through the timer callback) since 7.69,
++ # which is below the version we require, so this is
++ # redundant there and costs one timeout per
++ # max_buffer_size of body. But without it a libcurl
++ # older than that stalls until unrelated socket
++ # activity happens to wake the transfer, and a
++ # streaming fetch that used to work would hang.
++ self.client._set_timeout(0)
++
++ def finish(self) -> None:
++ """Deliver any remaining data at the end of the request.
++
++ The curl handle may be reset and reused after this, so we must not
++ touch it again.
++ """
++ self.curl = None
++ self.paused = False
++ self.flush()
++
++
+ class CurlAsyncHTTPClient(AsyncHTTPClient):
+ def initialize( # type: ignore
+- self, max_clients: int = 10, defaults: Optional[Dict[str, Any]] = None
++ self,
++ max_clients: int = 10,
++ defaults: Optional[Dict[str, Any]] = None,
++ max_body_size: int = 104857600,
+ ) -> None:
+ super().initialize(defaults=defaults)
++ self.max_body_size = max_body_size
+ # Typeshed is incomplete for CurlMulti, so just use Any for now.
+ self._multi = pycurl.CurlMulti() # type: Any
+ self._multi.setopt(pycurl.M_TIMERFUNCTION, self._set_timeout)
+@@ -203,6 +330,8 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
+ curl.info = { # type: ignore
+ "headers": httputil.HTTPHeaders(),
+ "buffer": BytesIO(),
++ "streaming_buffer": None,
++ "body_too_large": False,
+ "request": request,
+ "callback": callback,
+ "queue_start_time": queue_start_time,
+@@ -243,9 +372,23 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
+ info = curl.info # type: ignore
+ curl.info = None # type: ignore
+ self._multi.remove_handle(curl)
++ streaming_buffer = info["streaming_buffer"]
++ body_too_large = info["body_too_large"]
++ if streaming_buffer is not None:
++ body_too_large = body_too_large or streaming_buffer.body_too_large
++ # Make sure everything that was received reaches the
++ # streaming_callback before the final response.
++ try:
++ streaming_buffer.finish()
++ except Exception:
++ self.handle_callback_exception(info["request"].streaming_callback)
+ buffer = info["buffer"]
+ if curl_error:
+ assert curl_message is not None
++ if body_too_large:
++ curl_message = "body exceeded max_body_size of %d bytes" % (
++ self.max_body_size,
++ )
+ error = CurlError(curl_error, curl_message) # type: Optional[CurlError]
+ assert error is not None
+ code = error.code
+@@ -344,15 +487,31 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
+ self._curl_header_callback, headers, request.header_callback
+ ),
+ )
++ write_function: Callable[[bytes], int]
+ if request.streaming_callback:
+
+- def write_function(b: Union[bytes, bytearray]) -> int:
+- assert request.streaming_callback is not None
+- self.io_loop.add_callback(request.streaming_callback, b)
+- return len(b)
+-
++ streaming_buffer = _CurlStreamingBuffer(
++ self, curl, request.streaming_callback
++ )
++ curl.info["streaming_buffer"] = streaming_buffer # type: ignore
++ write_function = streaming_buffer.write
+ else:
+- write_function = buffer.write # type: ignore
++ # Count the buffered body against max_body_size, as
++ # _CurlStreamingBuffer does for the streaming case. Here the
++ # whole body is held in memory, so a response that decompresses
++ # past the limit cannot be retrieved at all -- only refused
++ # before it exhausts memory. libcurl's CURLOPT_MAXFILESIZE is
++ # no help: outside of very recent versions it is measured
++ # against the compressed size, which a bomb keeps small.
++ def write_function(chunk: bytes) -> int:
++ if buffer.tell() + len(chunk) > self.max_body_size:
++ curl.info["body_too_large"] = True # type: ignore
++ # Writing less than we were given makes libcurl fail
++ # the transfer with CURLE_WRITE_ERROR; _finish uses the
++ # flag above to replace its message with ours.
++ return 0
++ return buffer.write(chunk)
++
+ curl.setopt(pycurl.WRITEFUNCTION, write_function)
+ curl.setopt(pycurl.FOLLOWLOCATION, request.follow_redirects)
+ curl.setopt(pycurl.MAXREDIRS, request.max_redirects)
+diff --git a/tornado/test/circlerefs_test.py b/tornado/test/circlerefs_test.py
+index d5f7e969..0907aa86 100644
+--- a/tornado/test/circlerefs_test.py
++++ b/tornado/test/circlerefs_test.py
+@@ -21,6 +21,14 @@ import tornado
+ from tornado import web, gen, httpclient
+ from tornado.test.util import skipNotCPython
+
++try:
++ import pycurl
++except ImportError:
++ pycurl = None # type: ignore
++
++if pycurl is not None:
++ from tornado.curl_httpclient import CurlAsyncHTTPClient
++
+
+ def find_circular_references(garbage):
+ """Find circular references in a list of objects.
+@@ -100,6 +108,11 @@ def assert_no_cycle_garbage():
+ gc.enable()
+
+
++class OkHandler(web.RequestHandler):
++ def get(self):
++ self.write("ok\n")
++
++
+ # GC behavior is cpython-specific
+ @skipNotCPython
+ class CircleRefsTest(unittest.TestCase):
+@@ -137,7 +150,7 @@ class CircleRefsTest(unittest.TestCase):
+ self.assertIn(" name=b", str(cm.exception))
+ self.assertNotIn(" name=c", str(cm.exception))
+
+- async def run_handler(self, handler_class):
++ async def run_handler(self, handler_class, client_factory=None, **fetch_kwargs):
+ app = web.Application(
+ [
+ (r"/", handler_class),
+@@ -147,14 +160,18 @@ class CircleRefsTest(unittest.TestCase):
+ server = tornado.httpserver.HTTPServer(app)
+ server.add_socket(socket)
+
+- client = httpclient.AsyncHTTPClient()
++ # The client must be constructed here rather than by the caller: it
++ # binds to the IOLoop that is current when it is created.
++ if client_factory is None:
++ client_factory = httpclient.AsyncHTTPClient
++ client = client_factory()
+ with assert_no_cycle_garbage():
+ # Only the fetch (and the corresponding server-side handler)
+ # are being tested for cycles. In particular, the Application
+ # object has internal cycles (as of this writing) which we don't
+ # care to fix since in real world usage the Application object
+ # is effectively a global singleton.
+- await client.fetch(f"http://127.0.0.1:{port}/")
++ await client.fetch(f"http://127.0.0.1:{port}/", **fetch_kwargs)
+ client.close()
+ server.stop()
+ socket.close()
+@@ -190,6 +207,33 @@ class CircleRefsTest(unittest.TestCase):
+
+ asyncio.run(self.run_handler(Handler))
+
++ # The tests above vary the server-side handler against one client. These
++ # vary the client instead: a streaming_callback takes a different path
++ # through both client implementations than a buffered response does, and
++ # curl_httpclient keeps per-request state on pooled curl handles.
++
++ def test_streaming_callback(self):
++ asyncio.run(self.run_handler(OkHandler, streaming_callback=lambda chunk: None))
++
++ @unittest.skipIf(pycurl is None, "pycurl module not present")
++ def test_curl_httpclient(self):
++ asyncio.run(
++ self.run_handler(
++ OkHandler,
++ client_factory=lambda: CurlAsyncHTTPClient(force_instance=True),
++ )
++ )
++
++ @unittest.skipIf(pycurl is None, "pycurl module not present")
++ def test_curl_httpclient_streaming_callback(self):
++ asyncio.run(
++ self.run_handler(
++ OkHandler,
++ client_factory=lambda: CurlAsyncHTTPClient(force_instance=True),
++ streaming_callback=lambda chunk: None,
++ )
++ )
++
+ def test_run_on_executor(self):
+ # From https://github.com/tornadoweb/tornado/issues/2620
+ #
+diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py
+index ac3cccc5..d7eb1adf 100644
+--- a/tornado/test/curl_httpclient_test.py
++++ b/tornado/test/curl_httpclient_test.py
+@@ -1,12 +1,23 @@
++import gc
+ from hashlib import md5
+ import os
+ import ssl
+ import unittest
++from unittest import mock
++import weakref
+
++from tornado import gen
+ from tornado.escape import utf8
++from tornado.log import app_log
+ from tornado.netutil import ssl_options_to_context
+ from tornado.test import httpclient_test
+-from tornado.testing import AsyncHTTPSTestCase, AsyncHTTPTestCase
++from tornado.test.util import skipNotCPython
++from tornado.testing import (
++ AsyncHTTPSTestCase,
++ AsyncHTTPTestCase,
++ AsyncTestCase,
++ ExpectLog,
++)
+ from tornado.web import Application, RequestHandler
+
+ try:
+@@ -15,17 +26,106 @@ except ImportError:
+ pycurl = None # type: ignore
+
+ if pycurl is not None:
+- from tornado.curl_httpclient import CurlAsyncHTTPClient
++ from tornado.curl_httpclient import (
++ CurlAsyncHTTPClient,
++ CurlError,
++ _CurlStreamingBuffer,
++ )
++
++
++def create_client(**kwargs):
++ return CurlAsyncHTTPClient(
++ force_instance=True, defaults=dict(allow_ipv6=False), **kwargs
++ )
+
+
+ @unittest.skipIf(pycurl is None, "pycurl module not present")
+ class CurlHTTPClientCommonTestCase(httpclient_test.HTTPClientCommonTestCase):
++ # libcurl decodes the Content-Encoding named by the response whether or
++ # not we listed it in Accept-Encoding, so brotli is reachable whenever
++ # libcurl was built with it (which is common).
++ decompresses_brotli = pycurl is not None and "brotli" in pycurl.version
++
+ def get_http_client(self):
+ client = CurlAsyncHTTPClient(defaults=dict(allow_ipv6=False))
+ # make sure AsyncHTTPClient magic doesn't give us the wrong class
+ self.assertTrue(isinstance(client, CurlAsyncHTTPClient))
+ return client
+
++ def test_streaming_pause_and_resume(self):
++ # With a small buffer the transfer is paused and resumed many times
++ # over one response. libcurl re-delivers the chunk that was in
++ # flight when it was paused, so that chunk must not be consumed
++ # twice.
++ chunks: list[bytes] = []
++ with mock.patch.object(_CurlStreamingBuffer, "max_buffer_size", 1024):
++ response = self.fetch("/large_body", streaming_callback=chunks.append)
++ self.assertEqual(response.code, 200)
++ self.assertEqual(b"".join(chunks), httpclient_test.large_body())
++
++ def test_concurrent_paused_streams(self):
++ # Several streaming requests in flight at once, each pausing and
++ # resuming repeatedly: pausing one transfer must not disturb another.
++ client = create_client(max_clients=4)
++ buffers = [bytearray() for _ in range(8)]
++ try:
++ with mock.patch.object(_CurlStreamingBuffer, "max_buffer_size", 1024):
++ self.io_loop.run_sync(
++ lambda: gen.multi(
++ [
++ client.fetch(
++ self.get_url("/large_body"),
++ streaming_callback=buf.extend,
++ )
++ for buf in buffers
++ ]
++ )
++ )
++ finally:
++ client.close()
++ for buf in buffers:
++ self.assertEqual(bytes(buf), httpclient_test.large_body())
++
++ @skipNotCPython
++ def test_client_not_leaked_by_write_callbacks(self):
++ # The WRITEFUNCTION callbacks installed for both the buffered and
++ # the streaming path hold references to the client and to the curl
++ # handle. They must not keep the client alive after close().
++
++ def use_and_close_a_client():
++ client = create_client()
++ self.io_loop.run_sync(lambda: client.fetch(self.get_url("/hello")))
++ self.io_loop.run_sync(
++ lambda: client.fetch(
++ self.get_url("/large_body"),
++ streaming_callback=lambda chunk: None,
++ )
++ )
++ client.close()
++ return weakref.ref(client)
++
++ ref = use_and_close_a_client()
++ gc.collect()
++ self.assertIsNone(ref())
++
++ def test_streaming_callback_exception(self):
++ # An exception in the streaming_callback is logged, and does not
++ # stop the transfer or leave it paused forever.
++ chunks: list[bytes] = []
++
++ def streaming_callback(chunk):
++ chunks.append(chunk)
++ if len(chunks) == 1:
++ raise ZeroDivisionError()
++
++ with mock.patch.object(_CurlStreamingBuffer, "max_buffer_size", 1024):
++ with ExpectLog(app_log, "Exception in callback"):
++ response = self.fetch(
++ "/large_body", streaming_callback=streaming_callback
++ )
++ self.assertEqual(response.code, 200)
++ self.assertEqual(b"".join(chunks), httpclient_test.large_body())
++
+
+ class DigestAuthHandler(RequestHandler):
+ def initialize(self, username, password):
+@@ -82,7 +182,7 @@ class CustomFailReasonHandler(RequestHandler):
+ class CurlHTTPClientTestCase(AsyncHTTPTestCase):
+ def setUp(self):
+ super().setUp()
+- self.http_client = self.create_client()
++ self.http_client = create_client()
+
+ def get_app(self):
+ return Application(
+@@ -98,11 +198,6 @@ class CurlHTTPClientTestCase(AsyncHTTPTestCase):
+ ]
+ )
+
+- def create_client(self, **kwargs):
+- return CurlAsyncHTTPClient(
+- force_instance=True, defaults=dict(allow_ipv6=False), **kwargs
+- )
+-
+ def test_digest_auth(self):
+ response = self.fetch(
+ "/digest", auth_mode="digest", auth_username="foo", auth_password="bar"
+@@ -220,3 +315,118 @@ class CurlHTTPClientReuseCertsTestCase(AsyncHTTPSTestCase):
+ )
+ response = self.fetch(self.get_url("/client_cert"))
+ self.assertEqual(response.body, b"no client cert")
++
++
++@unittest.skipIf(pycurl is None, "pycurl module not present")
++class CurlHTTPClientMaxBodySizeTestCase(httpclient_test.HTTPClientMaxBodySizeTestCase):
++ # curl_httpclient does not log when it refuses a body; the message is
++ # carried by the CurlError instead.
++ def get_http_client(self):
++ # max_clients=1 so that each test also reuses the curl handle left
++ # behind by the one before it.
++ return create_client(max_body_size=httpclient_test.MAX_BODY_SIZE, max_clients=1)
++
++ def test_refusal_message(self):
++ # The generic test only checks that the body was refused; make sure
++ # the error says why, since libcurl's own message for a short write
++ # ("Failure writing output to destination") would not.
++ with self.assertRaises(CurlError) as cm:
++ self.fetch("/bomb", raise_error=True)
++ self.assertIn("max_body_size", str(cm.exception))
++
++ def test_redirect_body_not_counted(self):
++ # libcurl discards the body of a redirect that it follows, so a
++ # redirect carrying a large body of its own does not count towards
++ # the limit for the response that follows it. Not shared:
++ # simple_httpclient applies max_body_size to the redirect response
++ # too, and refuses this one before following it.
++ response = self.fetch("/body_bearing_redirect")
++ response.rethrow()
++ self.assertEqual(response.body, b"a" * httpclient_test.MAX_BODY_SIZE)
++
++
++@unittest.skipIf(pycurl is None, "pycurl module not present")
++class CurlStreamingBufferTest(AsyncTestCase):
++ """Unit tests for the buffer that feeds a ``streaming_callback``."""
++
++ def setUp(self):
++ super().setUp()
++ self.timeouts_requested: list[int] = []
++ test = self
++
++ class FakeClient:
++ io_loop = test.io_loop
++ max_body_size = 1024 * 1024
++
++ def _set_timeout(self, msecs):
++ test.timeouts_requested.append(msecs)
++
++ self.client = FakeClient()
++
++ def make_buffer(self, curl, callback):
++ return _CurlStreamingBuffer(
++ self.client, # type: ignore[arg-type]
++ curl,
++ callback,
++ )
++
++ def test_unpause_asks_for_a_socket_action(self):
++ # Only libcurl 8.x reports through the timer callback that an
++ # unpaused transfer is runnable again; older versions leave it
++ # stalled unless we ask for a socket_action ourselves.
++ paused = []
++
++ class FakeCurl:
++ def pause(self, flags):
++ paused.append(flags)
++
++ chunks: list[bytes] = []
++ buf = self.make_buffer(FakeCurl(), chunks.append)
++ with mock.patch.object(_CurlStreamingBuffer, "max_buffer_size", 4):
++ self.assertEqual(buf.write(b"hello"), 5)
++ self.assertEqual(buf.write(b"world"), pycurl.WRITEFUNC_PAUSE)
++ buf.flush()
++ self.assertEqual(chunks, [b"hello"])
++ self.assertEqual(paused, [pycurl.PAUSE_CONT])
++ self.assertEqual(self.timeouts_requested, [0])
++
++ def test_body_too_large(self):
++ # Exceeding the client's max_body_size makes write() report a short
++ # write, which libcurl turns into a failed transfer, and records why
++ # so that _finish can say so.
++ class FakeCurl:
++ def pause(self, flags):
++ raise AssertionError("should not pause")
++
++ chunks: list[bytes] = []
++ buf = self.make_buffer(FakeCurl(), chunks.append)
++ self.client.max_body_size = 8
++ self.assertEqual(buf.write(b"12345"), 5)
++ self.assertFalse(buf.body_too_large)
++ # 5 + 5 > 8, so this chunk is refused rather than truncated.
++ self.assertEqual(buf.write(b"67890"), 0)
++ self.assertTrue(buf.body_too_large)
++ buf.flush()
++ self.assertEqual(chunks, [b"12345"])
++
++ def test_unpause_error_is_ignored(self):
++ # When a transfer fails while it is paused, libcurl reports the
++ # failure from curl_easy_pause instead of from the write callback.
++ # That is not an error in the flush itself, and must not be raised
++ # into the IOLoop: _finish will pass the failure to the callback.
++ class FakeCurl:
++ def pause(self, flags):
++ raise pycurl.error(pycurl.E_WRITE_ERROR, "write error")
++
++ chunks: list[bytes] = []
++ buf = self.make_buffer(FakeCurl(), chunks.append)
++ with mock.patch.object(_CurlStreamingBuffer, "max_buffer_size", 4):
++ self.assertEqual(buf.write(b"hello"), 5)
++ # The buffer is full, so this chunk is left with libcurl.
++ self.assertEqual(buf.write(b"world"), pycurl.WRITEFUNC_PAUSE)
++ self.assertTrue(buf.paused)
++ buf.flush()
++ self.assertEqual(chunks, [b"hello"])
++ self.assertFalse(buf.paused)
++ # A failed unpause is not worth a socket_action.
++ self.assertEqual(self.timeouts_requested, [])
+diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
+index f009f190..46300200 100644
+--- a/tornado/test/httpclient_test.py
++++ b/tornado/test/httpclient_test.py
+@@ -1,17 +1,22 @@
+ import base64
+ import binascii
++import contextlib
+ from contextlib import closing
+ import copy
++import functools
+ import gzip
++import logging
+ import threading
+ import datetime
+ from io import BytesIO
+ import subprocess
+ import sys
+ import time
++import tracemalloc
+ import typing # noqa: F401
+ import unicodedata
+ import unittest
++import zlib
+
+ from tornado.escape import utf8, native_str, to_unicode, json_encode, json_decode
+ from tornado import gen
+@@ -28,7 +33,7 @@ from tornado.iostream import IOStream
+ from tornado.log import gen_log, app_log
+ from tornado import netutil
+ from tornado.testing import AsyncHTTPTestCase, bind_unused_port, gen_test, ExpectLog
+-from tornado.test.util import ignore_deprecation
++from tornado.test.util import abstract_base_test, ignore_deprecation
+ from tornado.web import Application, RequestHandler, url
+ from tornado.httputil import format_timestamp, HTTPHeaders
+
+@@ -161,12 +166,108 @@ class EchoHeadersHandler(RequestHandler):
+ self.write(json_encode(dict(self.request.headers.get_all())))
+
+
++# Decompressed size of the response served by `GzipBombHandler`. Kept below
++# every implementation's default ``max_body_size`` of 100MB so that a
++# streaming_callback can retrieve the whole thing; raising it past that limit
++# would turn this into a test of the limit instead (see
++# `HTTPClientMaxBodySizeTestCase`).
++GZIP_BOMB_SIZE = 64 * 1024 * 1024
++
++
++# Decompressed size of the bomb used for the buffered (no
++# streaming_callback) case. Larger than any implementation is willing to
++# hold in memory, so such a request cannot complete and can only be
++# refused.
++UNBUFFERABLE_BOMB_SIZE = 256 * 1024 * 1024
++
++
++@functools.lru_cache(maxsize=None)
++def gzip_bomb(size: int = GZIP_BOMB_SIZE) -> bytes:
++ """Returns a small gzip stream that expands to ``size`` bytes.
++
++ Compressed incrementally so that building it does not itself allocate
++ the decompressed size.
++ """
++ compressor = zlib.compressobj(9, zlib.DEFLATED, 16 + zlib.MAX_WBITS)
++ block = b"\0" * (1024 * 1024)
++ pieces = [compressor.compress(block) for _ in range(size // len(block))]
++ pieces.append(compressor.flush())
++ return b"".join(pieces)
++
++
++# A 421-byte brotli stream that expands to BROTLI_BOMB_SIZE bytes of zeros,
++# a ratio of ~637000:1 (deflate cannot exceed ~1032:1). Embedded as a
++# constant so that the test suite does not need a brotli module for Python.
++# Regenerate with:
++# import base64, brotli
++# base64.b64encode(brotli.compress(b"\0" * BROTLI_BOMB_SIZE, quality=11))
++BROTLI_BOMB_SIZE = 256 * 1024 * 1024
++BROTLI_BOMB = base64.b64decode(
++ "y///P/gnAOKxQCD3/o///3/wTwDEYRGA7v0f////4J8AiMMCAN37P/7//8E/ARCHBQC6"
++ "93/8//+DfwIgDgsAdO//+P//B/8EQBwWAOje//H//w/+CYA4LADQvf/j//8f/BMAcVgA"
++ "oHv/x///P/gnAOKwAED3/o///3/wTwDEYQGA7v0f////4J8AiMMCAN37P/7//8E/ARCH"
++ "BQC693/8//+DfwIgDgsAdO//+P//B/8EQBwWAOje//H//w/+CYA4LADQvf/j//8f/BMA"
++ "cVgAoHv/x///P/gnAOKwAED3/o///3/wTwDEYQGA7v0f////4J8AiMMCAN37P/7//8E/"
++ "ARCHBQC693/8//+DfwIgDgsAdO//+P//B/8EQBwWAOje//H//w/+CYA4LADQvf/j//8f"
++ "/BMAcVgAoHv/x///P/gnAOKwAED3/o///3/wTwDEYQGA7v0f////4J8AiMMCAN37P/7/"
++ "/8E/ARCHBQC693/8//+DfwIgDgsAdO//+P//B/8EQBwWAOje//H//w/+CYA4LADQvf/j"
++ "//8f/BMAcVgAoHv/Nw=="
++)
++
++
++@functools.lru_cache(maxsize=None)
++def large_body() -> bytes:
++ """Returns a body big enough to be delivered in several chunks.
++
++ The contents are non-repeating so that a chunk which was dropped,
++ duplicated or delivered out of order can be detected.
++ """
++ return b"".join(b"%09d\n" % i for i in range(100000))
++
++
++class GzipBombHandler(RequestHandler):
++ """Sends a small response that decompresses to a very large one."""
++
++ def get(self):
++ size = int(self.get_argument("size", str(GZIP_BOMB_SIZE)))
++ # Set Content-Encoding manually to avoid automatic gzip encoding.
++ self.set_header("Content-Encoding", "gzip")
++ self.write(gzip_bomb(size))
++
++
++class BrotliBombHandler(RequestHandler):
++ """Sends a brotli-encoded bomb that the client never asked for.
++
++ No client implementation requests brotli, but libcurl builds its decoder
++ from the ``Content-Encoding`` of the response without checking it against
++ what was requested, so ``curl_httpclient`` decodes this anyway.
++ """
++
++ def get(self):
++ self.set_header("Content-Encoding", "br")
++ self.write(BROTLI_BOMB)
++
++
++class LargeBodyHandler(RequestHandler):
++ def get(self):
++ # An incompressible content type, so that the server's gzip
++ # transform leaves the body alone and this exercises the plain
++ # streaming path.
++ self.set_header("Content-Type", "application/octet-stream")
++ self.write(large_body())
++
++
+ # These tests end up getting run redundantly: once here with the default
+ # HTTPClient implementation, and then again in each implementation's own
+ # test suite.
+
+
+ class HTTPClientCommonTestCase(AsyncHTTPTestCase):
++ # Set by subclasses whose client decodes ``Content-Encoding: br``. No
++ # implementation asks for brotli, so this is only true where the client
++ # decodes an encoding it did not request (see BrotliBombHandler).
++ decompresses_brotli = False
++
+ def get_app(self):
+ return Application(
+ [
+@@ -187,6 +288,9 @@ class HTTPClientCommonTestCase(AsyncHTTPTestCase):
+ url("/invalid_gzip", InvalidGzipHandler),
+ url("/header-encoding", HeaderEncodingHandler),
+ url("/echo_headers", EchoHeadersHandler),
++ url("/gzip_bomb", GzipBombHandler),
++ url("/brotli_bomb", BrotliBombHandler),
++ url("/large_body", LargeBodyHandler),
+ ],
+ gzip=True,
+ )
+@@ -228,6 +332,91 @@ class HTTPClientCommonTestCase(AsyncHTTPTestCase):
+ self.assertEqual(chunks, [b"Hello world!"])
+ self.assertFalse(response.body)
+
++ def test_streaming_callback_large_body(self):
++ # A body large enough to arrive in several chunks must reach the
++ # callback exactly once each, in order.
++ chunks: list[bytes] = []
++ response = self.fetch("/large_body", streaming_callback=chunks.append)
++ self.assertEqual(response.code, 200)
++ self.assertEqual(b"".join(chunks), large_body())
++ self.assertFalse(response.body)
++
++ def test_streaming_decompression_bomb(self):
++ # A malicious server can turn a small compressed response into an
++ # arbitrarily large decompressed one. With a streaming_callback it
++ # must be possible to consume the whole thing without the client
++ # buffering more than a bounded amount of it at a time.
++ gzip_bomb() # Precompute so it isn't counted in the measurement.
++ received = 0
++
++ def streaming_callback(chunk):
++ nonlocal received
++ received += len(chunk)
++
++ tracemalloc.start()
++ try:
++ tracemalloc.reset_peak()
++ response = self.fetch("/gzip_bomb", streaming_callback=streaming_callback)
++ peak = tracemalloc.get_traced_memory()[1]
++ finally:
++ tracemalloc.stop()
++
++ self.assertEqual(response.code, 200)
++ self.assertEqual(received, GZIP_BOMB_SIZE)
++ self.assertFalse(response.body)
++ self.assertLess(peak, 16 * 1024 * 1024)
++
++ def test_decompression_bomb_without_streaming_callback(self):
++ # Without a streaming_callback the whole body has to be buffered, so
++ # a bomb this size cannot be retrieved at all. It has to be refused
++ # before it exhausts memory rather than buffered in full.
++ gzip_bomb(UNBUFFERABLE_BOMB_SIZE) # Precompute.
++
++ tracemalloc.start()
++ try:
++ tracemalloc.reset_peak()
++ with self.assertRaises(HTTPError):
++ self.fetch(
++ "/gzip_bomb?size=%d" % UNBUFFERABLE_BOMB_SIZE, raise_error=True
++ )
++ peak = tracemalloc.get_traced_memory()[1]
++ finally:
++ tracemalloc.stop()
++
++ # Well short of the decompressed size: the point is that the body
++ # was refused, not accumulated.
++ self.assertLess(peak, UNBUFFERABLE_BOMB_SIZE // 2)
++
++ def test_streaming_unsolicited_brotli_bomb(self):
++ # A client that decodes an encoding it did not request can be
++ # reached by a codec whose expansion ratio is hundreds of times
++ # deflate's. The transfer need not survive -- libcurl aborts it once
++ # its own pause buffer cannot hold the expansion -- but either way
++ # the expansion must not be buffered.
++ if not self.decompresses_brotli:
++ self.skipTest("client does not decompress Content-Encoding: br")
++ received = 0
++
++ def streaming_callback(chunk):
++ nonlocal received
++ received += len(chunk)
++
++ tracemalloc.start()
++ try:
++ tracemalloc.reset_peak()
++ try:
++ self.fetch("/brotli_bomb", streaming_callback=streaming_callback)
++ except HTTPError:
++ pass
++ peak = tracemalloc.get_traced_memory()[1]
++ finally:
++ tracemalloc.stop()
++
++ # More than was sent on the wire, i.e. the response really was
++ # decoded with an encoding we never asked for.
++ self.assertGreater(received, len(BROTLI_BOMB))
++ self.assertLess(peak, 16 * 1024 * 1024)
++
+ def test_post(self):
+ response = self.fetch("/post", method="POST", body="arg1=foo&arg2=bar")
+ self.assertEqual(response.code, 200)
+@@ -1017,3 +1206,121 @@ class HTTPErrorTestCase(unittest.TestCase):
+ e = cm.exception
+ self.assertEqual(str(e), "HTTP 403: Forbidden")
+ self.assertEqual(repr(e), "HTTP 403: Forbidden")
++
++
++# Limit used by `HTTPClientMaxBodySizeTestCase`. Subclasses configure their
++# client with it.
++MAX_BODY_SIZE = 1024 * 64
++
++
++class MaxBodySizeSmallHandler(RequestHandler):
++ def get(self):
++ self.set_header("Content-Type", "application/octet-stream")
++ self.write(b"a" * MAX_BODY_SIZE)
++
++
++class MaxBodySizeLargeHandler(RequestHandler):
++ def get(self):
++ self.set_header("Content-Type", "application/octet-stream")
++ self.write(b"a" * (MAX_BODY_SIZE + 1))
++
++
++class BodyBearingRedirectHandler(RequestHandler):
++ """A redirect carrying a body of its own, larger than the limit.
++
++ Implementations differ here: libcurl discards the body of a redirect it
++ follows, while simple_httpclient applies ``max_body_size`` to every
++ response including the redirect, and so refuses this one before
++ following it.
++ """
++
++ def prepare(self):
++ self.write("x" * (MAX_BODY_SIZE * 2))
++ self.redirect("/small")
++
++
++@abstract_base_test
++class HTTPClientMaxBodySizeTestCase(AsyncHTTPTestCase):
++ """Tests for the limit on a response body held in memory.
++
++ Without a ``streaming_callback`` the whole body is buffered, so a
++ response larger than the client is prepared to hold cannot be retrieved
++ at all -- it can only be refused. Subclasses supply a client configured
++ with `MAX_BODY_SIZE`.
++ """
++
++ # Format of the message the implementation logs to `.gen_log` when it
++ # refuses a body, or None if it does not log one. ``{reason}`` is filled
++ # in with why the body was refused.
++ refusal_log_format = None # type: typing.Optional[str]
++
++ def get_app(self):
++ return Application(
++ [
++ url("/small", MaxBodySizeSmallHandler),
++ url("/large", MaxBodySizeLargeHandler),
++ url("/bomb", GzipBombHandler),
++ url("/body_bearing_redirect", BodyBearingRedirectHandler),
++ ]
++ )
++
++ def assert_refused(self, path, reason, **fetch_kwargs):
++ with contextlib.ExitStack() as stack:
++ if self.refusal_log_format is not None:
++ stack.enter_context(
++ ExpectLog(
++ gen_log,
++ self.refusal_log_format.format(reason=reason),
++ level=logging.INFO,
++ )
++ )
++ with self.assertRaises(HTTPError) as cm:
++ self.fetch(path, raise_error=True, **fetch_kwargs)
++ self.assertEqual(cm.exception.code, 599)
++
++ def assert_streaming_refused(self, path, reason):
++ received = 0
++
++ def streaming_callback(chunk):
++ nonlocal received
++ received += len(chunk)
++
++ self.assert_refused(path, reason=reason, streaming_callback=streaming_callback)
++ # Nothing past the limit reached the callback.
++ self.assertLessEqual(received, MAX_BODY_SIZE)
++
++ def test_small_body(self):
++ # A body of exactly max_body_size is accepted.
++ response = self.fetch("/small")
++ response.rethrow()
++ self.assertEqual(response.body, b"a" * MAX_BODY_SIZE)
++
++ def test_large_body(self):
++ # One byte more is not.
++ self.assert_refused("/large", reason="Content-Length too long")
++
++ def test_decompressed_body_size(self):
++ # The limit is measured after decompression, so a compressed
++ # response cannot expand past it.
++ self.assert_refused("/bomb", reason="decompressed body too large")
++
++ def test_streaming_callback_over_limit(self):
++ # max_body_size applies to a streaming_callback as well as to a
++ # buffered response. It is a safeguard for applications that do not
++ # impose a limit of their own on what they do with the chunks
++ # (writing them to disk, say), so retrieving a response larger than
++ # this means raising the limit deliberately.
++ self.assert_streaming_refused("/large", reason="Content-Length too long")
++
++ def test_streaming_callback_decompressed_over_limit(self):
++ # As above, measured after decompression.
++ self.assert_streaming_refused("/bomb", reason="decompressed body too large")
++
++ def test_reuse_after_refusal(self):
++ # Refusing a body must not leave the client unable to make the next
++ # request. This matters most for curl_httpclient, which reuses a
++ # pool of curl handles.
++ self.assert_refused("/large", reason="Content-Length too long")
++ response = self.fetch("/small")
++ response.rethrow()
++ self.assertEqual(response.body, b"a" * MAX_BODY_SIZE)
+diff --git a/tornado/test/simple_httpclient_test.py b/tornado/test/simple_httpclient_test.py
+index a40435e8..03da1c34 100644
+--- a/tornado/test/simple_httpclient_test.py
++++ b/tornado/test/simple_httpclient_test.py
+@@ -789,34 +789,13 @@ class MaxHeaderSizeTest(AsyncHTTPTestCase):
+ self.fetch("/large", raise_error=True)
+
+
+-class MaxBodySizeTest(AsyncHTTPTestCase):
+- def get_app(self):
+- class SmallBody(RequestHandler):
+- def get(self):
+- self.write("a" * 1024 * 64)
+-
+- class LargeBody(RequestHandler):
+- def get(self):
+- self.write("a" * 1024 * 100)
+-
+- return Application([("/small", SmallBody), ("/large", LargeBody)])
++class SimpleHTTPClientMaxBodySizeTestCase(
++ httpclient_test.HTTPClientMaxBodySizeTestCase
++):
++ refusal_log_format = "Malformed HTTP message from None: {reason}"
+
+ def get_http_client(self):
+- return SimpleAsyncHTTPClient(max_body_size=1024 * 64)
+-
+- def test_small_body(self):
+- response = self.fetch("/small")
+- response.rethrow()
+- self.assertEqual(response.body, b"a" * 1024 * 64)
+-
+- def test_large_body(self):
+- with ExpectLog(
+- gen_log,
+- "Malformed HTTP message from None: Content-Length too long",
+- level=logging.INFO,
+- ):
+- with self.assertRaises(HTTPStreamClosedError):
+- self.fetch("/large", raise_error=True)
++ return SimpleAsyncHTTPClient(max_body_size=httpclient_test.MAX_BODY_SIZE)
+
+
+ class MaxBufferSizeTest(AsyncHTTPTestCase):
+--
+2.35.6
+
@@ -12,6 +12,7 @@ SRC_URI += "file://CVE-2026-91990.patch \
file://CVE-2026-82397.patch \
file://CVE-2026-91991.patch \
file://CVE-2026-103261.patch \
+ file://CVE-2026-103262.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/e412435febba4569c552c5c9054f1bf92bd171a9 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-103262 Signed-off-by: Yogita Urade <yurade@cisco.com> --- .../python3-tornado/CVE-2026-103262.patch | 1117 +++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 1 + 2 files changed, 1118 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103262.patch