From patchwork Thu Nov 5 07:08:14 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102990 Received: from rcdn-iport-9.cisco.com (rcdn-iport-9.cisco.com [173.37.86.80]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28858.1793862502132422203 for ; Wed, 04 Nov 2026 23:08:22 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=Axx29mhp; spf=pass (domain: cisco.com, ip: 173.37.86.80, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=5754; q=dns/txt; s=iport01; t=1793862502; x=1795072102; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=7IwY7AtzvKUwedeocAnsI6b4cRsjtgb+i2rViI/9Vyk=; b=Axx29mhpx2Ud9hhNgaXE8MotZ4SnWchU7FZxMj/KJ+GPgUXxPpfgBoXv 4rUFG1YnzC3GwmImJYGprV7ZE6FWraHKlloHECiM23MykDIinkmkOI8zZ TYw9eNPs/5zidVbyjSc0EcGQCW1kqZSn3uJ25T4l4bVLuUkdhnB76hm1V NA/hlO0eY7s6Wwhn7b6S0AlV8G1kgUqoIY3YSOduvDFkAuQahyoQPipDY BE5VLC0J0Xa2bhWL1ISv3sgrQo7kcTPFXgAPqje7xRHf7RjvhbMx4ic3x VESNM8W4/tPfWatMowWWZV9Nv2uz54/y+lucQIg3BE/AocdgtmkmPHN65 A==; X-CSE-ConnectionGUID: GK/DhZzbQGq8PM8wVB9ZKA== X-CSE-MsgGUID: MfhMmwq6S9ygWEOca20oWw== X-IPAS-Result: A0AaAABGKuxq/4r/Ja1aHAEBAQEBAQcBARIBAQQEAQGBfAcBAQsBglZ1YENJjHKHOIIhA54hgX4PAQEBD0QNBAEBhD9GAo4dAiY0CQ4BAgQDAgMBAQEBAQEBAQEBAQEKAQEFAQEBAgEHBYEOE4ZPDZASAQIBAzIBGAE9HAMBAi8rIwgZgwIBgnQDEbw+giyBAYMpAT8CQ1DZS4FoAQsUAYE4AYU/iCNdGAGEfScbgUlEhH6BBYFcAQGBJwYLEIZeBIMugVqBA4wVAoFShnFIgR4DWSwBVRMNCgsHBYFoAzUSKhVuMh2BIz4XgQcbBgWBHYEegRKBB12BKyheIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZAsaBWEIAg0McjkQMB4kLwUcJkAbChgYDUgRLDcVGQQ+bgeSL4MRMV0BExgXNjIGG6cCoRGEKowkj0KFeBozhASBV5JCklQLmH2OCpU1TFCEaYFoPIFHCwczGggbFTuCZwlKGQ+KHoQNDguBaIF4gX+DZch3JjICCTIBAQcCBw4DC4FokAABJ4FWAQE IronPort-Data: A9a23:jNdaLayh84fZPVdGPxd6t+dhxyrEfRIJ4+MujC+fZmUNrF6WrkVTm DccWGrTb66NZ2T2fd1+OoWx/RwCuJODxt8xTAZprFhgHilAwSbn6Xt1DatR0we6dJCroJdPt p1GAjX4BJlqCCKa/lHyYuCJQUBUjcmgXqD7BPPPJhd/TAplTDZJoR94kobVuKYw6TSCK13L4 4yaT/H3Ygf/hWctaj5MscpvlTs21BjMkGJA1rABTagjUG/2zxE9EJ8ZLKetGHr0KqE8NvK6X evK0Iai9Wrf+Ro3Yvv9+losWhRXKlJ6FVHmZkt+A8BOsDAbzsAB+vpT2M4nVKtio27hc+adZ zl6ncfYpQ8BZsUgkQmGOvVSO3kW0aZuoNcrLZUj2CCe5xWuTpfi/xlhJG4VeqEYyPR9OE5Xp cQ9eC8zREGxpMvjldpXSsE07igiBNPgMIVavjRryivUSK5/B5vCWK7No9Rf2V/chOgXQq2YP JVfM2cyKk2bM3WjOX9PYH46tPa1nHDzfhVTqUmeouw85G27IAlZjeS2boGPK4PQLSlTtmOWp kfD4H2nOUALO+Oy7jGMqCqrufCayEsXX6pXTtVU7MVCh0WewGEWAhAaWVa35PW0lEO6c9ZeM FAPvC02oK4/8UamQtXwU1u/unHsg/IHc8BbH+t/7ESGzbDZpl7AQGMFVTVGLtchsafaWAAX6 7NApPuxbRQHjVFfYSv1Gmu8xd9qBRUoEA== IronPort-HdrOrdr: A9a23:rgnYeKwUJrjE8y97xqWxKrPwAL1zdoMgy1knxilNoHtuA6ilfq +V8sjzuSWYtN9VYgBCpTniAtjkfZqjz/9ICOAqVN/INjUO+lHYTr2KhrGM/9SPIUHDH5ZmtZ tIQuxZFMD6C0R8gILR5Qm1FMtl/fy8mZrY4ts3CxxWPHhXg2YK1XYeNjqm X-Talos-CUID: 9a23:Qz3EY2GNDQSbrX2cqmJI1wk4GvI3eETy1SfoBh+nN0w4ep68HAo= X-Talos-MUID: 9a23:C4h+vQw4hT+wDtlINnu2QPJV/f6aqIGgCGUIjoxYgJiVOhNdHDyU1mywfZByfw== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="543632972" Received: from rcdn-l-core-01.cisco.com ([173.37.255.138]) by rcdn-iport-9.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-01.cisco.com (Postfix) with ESMTPS id 05C73180001FC for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id 9D6C5CC12A7; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 1/6] python3-tornado: Fix CVE-2026-103261 Date: Wed, 4 Nov 2026 23:08:14 -0800 Message-Id: <20261105070819.2086816-2-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-01.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:29 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131863 This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. [1] https://github.com/tornadoweb/tornado/commit/03945136ea9746eccf61caf88edae39642e59c93 [2] https://nvd.nist.gov/vuln/detail/CVE-2026-103261 Signed-off-by: Yogita Urade --- .../python3-tornado/CVE-2026-103261.patch | 98 +++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 1 + 2 files changed, 99 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103261.patch diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103261.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103261.patch new file mode 100644 index 0000000000..f603b4b24a --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103261.patch @@ -0,0 +1,98 @@ +From 03945136ea9746eccf61caf88edae39642e59c93 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Sun, 13 Sep 2026 03:32:19 +0000 +Subject: [PATCH] httputil: Apply the argument count limit to query strings + +parse_body_arguments limits the number of fields in a urlencoded body +(added in 6.5.8), but HTTPServerRequest.__init__ parsed the query string +with no such limit, so the same superlinear parsing cost was reachable +through the request line instead of the body. + +Measured on the unpatched code, a query string with 400k fields took +3.2s of CPU to parse. In the default configuration the request line is +bounded by max_header_size (64KB), which caps this at roughly 12,700 +arguments and ~47ms per request, but that is still an order of magnitude +past the limit the body path enforces, and applications are free to +raise max_header_size. + +Pass the same max_arguments limit here. Over-limit query strings now +raise HTTPInputError, which the server already turns into a 400. + +The limit is read from the global _DEFAULT_PARSE_BODY_CONFIG because +HTTPServerRequest has no access to any per-connection configuration. +This is the same limitation the body path already has in practice: +_parse_body calls parse_body_arguments without a config, so the body +limit is likewise only adjustable through set_parse_body_config. + +CVE: CVE-2026-103261 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/03945136ea9746eccf61caf88edae39642e59c93] + +Co-Authored-By: Claude Opus 5 +Claude-Session: https://claude.ai/code/session_01JD7LUuFMdyPJB1Dwdq6fPg +Signed-off-by: Yogita Urade +--- + tornado/httputil.py | 14 +++++++++++++- + tornado/test/httputil_test.py | 15 +++++++++++++++ + 2 files changed, 28 insertions(+), 1 deletion(-) + +diff --git a/tornado/httputil.py b/tornado/httputil.py +index b80c796c..6a5db233 100644 +--- a/tornado/httputil.py ++++ b/tornado/httputil.py +@@ -550,7 +550,19 @@ class HTTPServerRequest: + + if uri is not None: + self.path, sep, self.query = uri.partition("?") +- self.arguments = parse_qs_bytes(self.query, keep_blank_values=True) ++ try: ++ self.arguments = parse_qs_bytes( ++ self.query, ++ keep_blank_values=True, ++ # The query string is bounded by max_header_size, but parsing ++ # is expensive enough per field to be worth limiting. Use the ++ # same limit as a urlencoded body. This reads the global ++ # config because HTTPServerRequest has no access to the ++ # per-connection configuration; see set_parse_body_config. ++ max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments, ++ ) ++ except Exception as e: ++ raise HTTPInputError("Invalid query string: %s" % e) from e + self.query_arguments = copy.deepcopy(self.arguments) + self.body_arguments = {} # type: Dict[str, List[bytes]] + +diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py +index afb15879..0e9d5c41 100644 +--- a/tornado/test/httputil_test.py ++++ b/tornado/test/httputil_test.py +@@ -11,6 +11,7 @@ from tornado.httputil import ( + HTTPInputError, + HTTPFile, + ParseMultipartConfig, ++ _DEFAULT_PARSE_BODY_CONFIG, + ) + from tornado.escape import utf8, native_str + from tornado.log import gen_log +@@ -658,6 +659,20 @@ class HTTPServerRequestTest(unittest.TestCase): + ) + self.assertNotIn("Canary", repr(request)) + ++ def test_query_arguments_within_limit(self): ++ n = _DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments ++ request = HTTPServerRequest(uri="/?" + "&".join("a=%d" % i for i in range(n))) ++ self.assertEqual(len(request.arguments["a"]), n) ++ ++ def test_max_query_arguments(self): ++ # The query string is subject to the same limit on the number of ++ # arguments as a urlencoded body: parsing is superlinear enough in ++ # the number of fields to be worth bounding. ++ n = _DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments + 1 ++ with self.assertRaises(HTTPInputError) as cm: ++ HTTPServerRequest(uri="/?" + "&".join("a=%d" % i for i in range(n))) ++ self.assertIn("Max number of fields exceeded", str(cm.exception)) ++ + + class ParseRequestStartLineTest(unittest.TestCase): + METHOD = "GET" +-- +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 332d5875e6..a1b016d818 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 @@ -11,6 +11,7 @@ SRC_URI[sha256sum] = "66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c SRC_URI += "file://CVE-2026-91990.patch \ file://CVE-2026-82397.patch \ file://CVE-2026-91991.patch \ + file://CVE-2026-103261.patch \ " inherit pypi python_setuptools_build_meta From patchwork Thu Nov 5 07:08:15 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102988 Received: from rcdn-iport-1.cisco.com (rcdn-iport-1.cisco.com [173.37.86.72]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28856.1793862501882047726 for ; Wed, 04 Nov 2026 23:08:24 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=V95DhRj1; spf=pass (domain: cisco.com, ip: 173.37.86.72, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=44159; q=dns/txt; s=iport01; t=1793862504; x=1795072104; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=JAaBW3y/tyuJZy/cykufwMVSbL9coEFqvWo48KXcX1Q=; b=V95DhRj1X/CeHePBZ9iKlbQ4wbp/0kujXkM5MCJGHgI5zLz+L/JQcuMS vZ+G/C8Lioa3F/wt+zaD0x8XUdZcii3bd5aUHRl6MZuxvFt8hffRkMvBu SA2+zfbZqS370Ly1Dl9cQLcm6h2zRTMLF4DewGtQRIbeZ/cCkNUnU/Sl4 XHNFDHs9jW/Dxu5DwViho01G/QEbL2RXv4aMomA22NsOLOHu2zugOfSaw LlA1udG777e1hQWWBBziz0FG8qFVgkWBTLC0pgFKg2R0jbrixvgQBLoXY Z4NHnucB1ZJOH6SzpEzQVh1Sh6YbowM27jEu8ef+A8K+1PUsfA3kGleCE A==; X-CSE-ConnectionGUID: 87Xc/jB4Twys8YBFEeDsHw== X-CSE-MsgGUID: JNzgi8aMTNSYWc+N72WvJw== X-IPAS-Result: A0BSAgDDKuxq/43/Ja1aglmCV3VgGilJlCqCIQOBE4IKji+MVRSBag8BAQEPRA0EAQGEP0YCjh0CJjQJDgECBAMCAwEBAQEBAQEBAQEBAQoBAQUBAQECAQcFgQ4Thk8NkBIBAgEDGgEMCwEUBRsiHAIBAQIZFiQHIwgQCYMCAYJxAwMRvD+BeTOBAYJPWz8CQ1DZS4FoAQsUAYE4gWESg02DA4ERhA9dGAFEhDknG4FJRIEVgnN2gQWBXAEBgS0LDwFohXYEgg2BIYFagQMJghmDG4Y+GgKBUlSGHUiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEdgR6BEoEHXYErKF4hgQiBdgKCUYIAAgFJQQ4HRVMJJUMSRyQiCBIJARMaLAqBGjU8CSY/FQxkCxoFYQgCDQxyORAwHiQvBRwmQBsKGBgNSBEsNxUZBD5uB5IvghkWNQ0OCwYBFhkCDA4IDhoEBQoBJQUBBB40KSEUESgWAQsjCQ4VGQOSQx0HFpAKgiGBNZ9chCqMJI9ChXgaM4QEgVeSQpJUC5YagmOOCokEjDojAgYYAU+EaYFoPIFHCwczGggbFTuCZwkWNBkPjisDAQoLgWiBeIF/WguCL8lIJjICCTIBAQcCBw4DC4FokAEleGABAQ IronPort-Data: A9a23:RdojqaAQc8+NERVW/3/iw5YqxClBgxIJ4kV8jS/XYbTApD901TMAz WNOXm+Aa//cMzCjc9l3Po+090lSupSBzNBmOVdlrnsFo1CmBibm6XV1Cm+qYkt+++WaFBoPA /02M4eGcYZsCCCM/n9BC5C5xVFkz6aEW7HgP+DNPyF1VGdMRTwo4f5Zs7ZRbrVA357jXGthh fuo+5eBYQb9h2YuWo4pw/vrRC1H7ayaVAww5jTSVdgT1HfCmn8cCo4oJK3ZBxPQXolOE+emc P3Ixbe/83mx109F5gSNy+uTnuUiG9Y+DCDW4pZkc/HKbitq+kTe5p0G2M80Mi+7vdkmc+dZk 72hvbToIesg0zaldO41C3G0GAkmVUFKFSOuzXWX6aSuI0P6n3TEk6peBwY0F9Ek5fdsUVBk2 9EVBCstcUXW7w626OrTpuhEnM8vKozveYgYoHwllW2fBvc9SpeFSKLPjTNa9G5v3YYVQrCEO pdfMGY2BPjDS0Un1lM/GY8kleulrnL+aDZf7lmSoMLb5kCOkFAqiOS8YIu9ltqiZ+NHkUfIr 0f62V/IPRpZEoPF9iSn7Sf57gPItWahMG4IL5W/7vNsjViZy2AfBRFTWValrP2Rjk+lR8kZL FQZ/Ccrp6U++EGnCN7nUHWFTGWspBUQXZ9UVuY98gzIkvaS6AeCDW9CRTlEADA7iPILqfUR/ gfht7vU6fZH7NV5lVr1Gm+okA6P IronPort-HdrOrdr: A9a23:zYYSe6jCujziNRBOHes7LjMXknBQXvYji2hC6mlwRA09TyX+rb HLoB1173HJYVoqNU3I3OrwW5VoIkmskKKdn7NxAV7KZmCP0wGVxcNZnOnfKlbbdBEWmNQw6U 4ZSchDIey1K0RmhsDn5wT9OdMhzN6btJ2Mv47lvhBQpcUAUdAY0++/YTzrdHFLeA== X-Talos-CUID: 9a23:cMr2+WnteBa9/ct0pjnrtC5G6xPXOS2C/HyXIWXkNXl4U+2kY3G99Y9/s9U7zg== X-Talos-MUID: 9a23:6QY4tAhuKuKFfyfNI2CBIsMpFuF67KX+AWUxt643hMLHE3MqBCm/k2Hi X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="544304989" Received: from rcdn-l-core-04.cisco.com ([173.37.255.141]) by rcdn-iport-1.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-04.cisco.com (Postfix) with ESMTPS id 061D7180005CE for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id A16B6CC12A8; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 2/6] python3-tornado: Fix CVE-2026-103262 Date: Wed, 4 Nov 2026 23:08:15 -0800 Message-Id: <20261105070819.2086816-3-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-04.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:29 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131867 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 --- .../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 diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103262.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103262.patch new file mode 100644 index 0000000000..22da4e9e7b --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103262.patch @@ -0,0 +1,1117 @@ +From e412435febba4569c552c5c9054f1bf92bd171a9 Mon Sep 17 00:00:00 2001 +From: Claude +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 +Claude-Session: https://claude.ai/code/session_01BD36EiRmzMSFqZeAqZu4gm +Signed-off-by: Yogita Urade +--- + 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 `_ 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 `_ 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 + 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 a1b016d818..2888090884 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 @@ -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 From patchwork Thu Nov 5 07:08:16 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102989 Received: from rcdn-iport-2.cisco.com (rcdn-iport-2.cisco.com [173.37.86.73]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28861.1793862504297912303 for ; Wed, 04 Nov 2026 23:08:24 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=Pl4norz4; spf=pass (domain: cisco.com, ip: 173.37.86.73, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=31935; q=dns/txt; s=iport01; t=1793862504; x=1795072104; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=nuTdES3VJCkcviYiO4n3MoJVNVeBBjYI1mV8puTnqRg=; b=Pl4norz4x5Ahn4wmRqqyRzW1tzAFG21iJoxu0CLaBuVtB6A9nMhEwkrl CK5hQd0khvRZjERwMsB+JGBHHvM7hrYHkIWbCZ6wBymnb17kBVBDVKxPo 7AkPUxtTzqon5Qppw6+47lRGjn1CPI/xqtd2TA9oWfOB79NMrXpU0RmDy KajB8IGvD6afjLtlN0TESDl796KkN7l/lIJbKDz4pyzZiHEWktp+x6kMu iKO6JAoLp1pJ/9eRQhgwoAV2CMQ+cAlfg0ni2sdBMEalqhqSFEtc+wykj bUaRXWJgFwxDBsVUD3Cb0iCCkzirHE3JD1ZsTS0zvrbtNpeIu+rTTWAfT Q==; X-CSE-ConnectionGUID: 9ib+lX2CSDu3xVekiX9RNA== X-CSE-MsgGUID: j0DrzgI6TxW6WaRPtqkM2g== X-IPAS-Result: A0AqAADDKuxq/43/Ja1aHAEBAQEBAQcBARIBAQQEAQGBfQYBAQsBglZ1YENJlCqCIQORTIxVFIFqDwEBAQ9EDQQBAYQ/RgKOHQImNQgOAQIEAwIDAQEBAQEBAQEBAQEBCgEBBQEBAQIBBwWBDhOGTw2QEgECAQMaAQwLARgBPRwDAQIvKyMIGYMCAYJ0AxG8P4F5M4EBgykBPwJDUNlLgWgBCxQBgTgBhT+II10YAUSEOScbgUlEhAh2gQWBXAEBgUcBhl4Egy6BWoEDhT2GWAKBUoZxSIEeA1ksAVUTDQoLBwWBaAM1EioVbjIdgSM+F4EHGwYFgR2BHoESgQddgSsoXiGBCIF2AoJRggACAUlBDgdFUwklQxJHJCIIEgkBExosCoEaNTwJJj8VDGQLGgVhCAINDHI5EDAeJC8FHCZAGwoYGA1IESw3FRkEPm4Hki+CcQ4LBwEeXBMBByICBBwCJggEAikGDHIBBCQeBicDkmBDj2SCIYE1n1yEKowkj0KFeBozhASUGZJUC5h9jgqJEYxYCw0BT4RpgWoDN4FHCwczGggbFTuCZwlKGQ+OOYFzgXiBf4JRgRTIdyYyAgkyAQEHAgcOAwuBaJACLYFPAQE IronPort-Data: A9a23:RKcIuK1hQkOahrR5ufbD5YVwkn2cJEfYwER7XKvMYLTBsI5bpzdRy zQWWDiDaKqPYWL2fo1/O9zk8E0A6MWAmNZhHgU/3Hw8FHgiRegpqji6wuYcGwvIc6UvmWo+t 512huHodZ5yEzmE4Ej9atANlFEkvYmQXL3wFeXYDS54QA5gWU8JhAlq8wIDqtYAbeORXUXX5 rsen+WFYAX7gmcsbjpOg06+gEoHUMra6WtwUmMWPZinjHeG/1EJAZQWI72GLneQauF8Au6gS u/f+6qy92Xf8g1FIovNfmHTKxBirhb6ZGBiu1IOM0SQqkEqSh8ajs7XAMEhhXJ/0F1lqTzeJ OJl7vRcQS9xVkHFdX90vxNwS0mSNoUekFPLzOTWXcG7lyX7n3XQL/pGXUA8fokooM1MLXwV5 cIVKzE9PyqlrrfjqF67YrEEasULNsLnOsYb/3pn1zycVapgSpHYSKKM7thdtNsyrpkRRrCFO IxDNGcpNUiZC/FMEg9/5JYWjfqyj3XyWzZZs1mS46Ew5gA/ySQsi+C3b4SPIYfiqcN9nB60v HDJ3jrFEi5EC9Wl8Sac8Vf2ibqa9c/8cMdIfFGizdZtmFCVy2kZBREaWFf+rfSnh0qWX9NEN 1dS/TIjq6U3/kGnQtTxGRqirxa5UgU0QdFcFag+rQqK0KeRu1rfDWkfRTkHY9sj3CMreQEXO payt4uBLVRSXHe9ExpxKp/8QeuOBBUo IronPort-HdrOrdr: A9a23:jquUDKATMsXKUXHlHemA55DYdb4zR+YMi2TDsHoBLSC9Hfb3qy nDppkmPFrP+VUssRIb6LW90de7IE80nKQdieJ6AV7hZniFhILCFu5fBOXZrwEIYxefysdtkY F9bqN5FNr8SXJ+jcr8/U2ENuxI+qjhzEht7t2utkuEimpRGsdd0zs= X-Talos-CUID: 9a23:3fdLMG+OVS9hfnmlA5qVvx8VEIMhQif291vNeF+GBGVoeOyvb3bFrQ== X-Talos-MUID: 9a23:RM03+QRJMDp3lCO7RXTu3B14JshJvZi+Nx4ymK8+p9CHEHZ/bmI= X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="530276233" Received: from rcdn-l-core-04.cisco.com ([173.37.255.141]) by rcdn-iport-2.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-04.cisco.com (Postfix) with ESMTPS id 0AAAC180005D7 for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id A9132CC12A9; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 3/6] python3-tornado: Fix CVE-2026-103263 Date: Wed, 4 Nov 2026 23:08:16 -0800 Message-Id: <20261105070819.2086816-4-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-04.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:29 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131868 This patch applies the upstream fix as referenced in [3], using the commit shown in [1] and dependent commit from [2]. Required companion fix for CVE-2026-103263: The symlink-hardening backport adds allowed_symlink_directory, but subclasses that override initialize() without calling the base method do not set it and fail with AttributeError. Include upstream commit 6bd9c4e0 and its regression tests with the CVE fix. [1] https://github.com/tornadoweb/tornado/commit/437ab5f76565403b3403438d2e555d11f8128d32 [2] https://github.com/tornadoweb/tornado/commit/6bd9c4e0fc10ae743006cd6a97543d15050f9594 [3] https://nvd.nist.gov/vuln/detail/CVE-2026-103263 [4] https://github.com/tornadoweb/tornado/issues/3724 Signed-off-by: Yogita Urade --- .../CVE-2026-103263-dependent.patch | 255 +++++++++++ .../python3-tornado/CVE-2026-103263.patch | 422 ++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 2 + 3 files changed, 679 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263-dependent.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263.patch diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263-dependent.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263-dependent.patch new file mode 100644 index 0000000000..168f91c147 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263-dependent.patch @@ -0,0 +1,255 @@ +From 6bd9c4e0fc10ae743006cd6a97543d15050f9594 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Tue, 15 Sep 2026 12:54:25 +0000 +Subject: [PATCH] web: Fix subclasses that do not set allowed_symlink_directory + +allowed_symlink_directory was added in 6.5.9 as an attribute assigned in +initialize(). Projects that subclass StaticFileHandler with their own +initialize() that does not call ours never set it, so every static file +request failed with + + AttributeError: 'FileFindHandler' object has no attribute + 'allowed_symlink_directory' + +Jupyter is the most prominent example, and it also shows why the +attribute cannot simply default to self.root: its FileFindHandler +searches a list of directories, so self.root is a tuple of paths, and +its validate_absolute_path passes the one that matched down to us. + +Give allowed_symlink_directory a class-level default of None, which +subclasses inherit whatever their initialize() does, and resolve that +default in validate_absolute_path against the root it was given. +_resolve_symlink_target becomes a static method taking only the values +its caller settled on, so that it cannot read a directory from self that +differs from the one the other path checks used. + +Fixes #3724 + +CVE: CVE-2026-103263 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/6bd9c4e0fc10ae743006cd6a97543d15050f9594] + +Co-Authored-By: Claude Opus 5 +Claude-Session: https://claude.ai/code/session_01Brgh48sZusx4pzHwygor7q +Signed-off-by: Yogita Urade +--- + tornado/test/web_test.py | 97 ++++++++++++++++++++++++++++++++++++++++ + tornado/web.py | 40 +++++++++++++---- + 2 files changed, 128 insertions(+), 9 deletions(-) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 4ac6fb98..fba23a7c 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -1676,6 +1676,103 @@ class StaticFileSymlinkTest(WebTestCase): + self.assertEqual(response.body, b"secret") + + ++class MultiRootStaticFileHandler(StaticFileHandler): ++ """A subclass that searches a list of directories. ++ ++ It replaces ``initialize`` without calling ``super().initialize()``, so ++ ``allowed_symlink_directory`` is never set, and it puts something other ++ than a string in ``self.root``, passing the directory that matched to ++ ``validate_absolute_path``. The symlink check must run against that ++ directory. Jupyter's ``FileFindHandler`` is built this way. ++ """ ++ ++ root: tuple # type: ignore[assignment] ++ ++ def initialize(self, path): # type: ignore[override] ++ self.root = tuple(os.path.abspath(p) + os.path.sep for p in path) ++ self.default_filename = None ++ ++ @classmethod ++ def get_absolute_path(cls, roots, path): # type: ignore[override] ++ for root in roots: ++ candidate = os.path.abspath(os.path.join(root, path)) ++ if os.path.exists(candidate): ++ return candidate ++ return os.path.abspath(os.path.join(roots[0], path)) ++ ++ def validate_absolute_path(self, root, absolute_path): # type: ignore[override] ++ for candidate in self.root: ++ if (absolute_path + os.path.sep).startswith(candidate): ++ root = candidate ++ break ++ return super().validate_absolute_path(root, absolute_path) ++ ++ ++@unittest.skipIf(os.name != "posix", "non-posix OS") ++class StaticFileMultiRootTest(WebTestCase): ++ """Subclasses that search several directories and never set ++ ``allowed_symlink_directory`` must keep working (issue #3724). ++ """ ++ ++ def setUp(self): ++ self.tmpdir = tempfile.mkdtemp() ++ self.addCleanup(shutil.rmtree, self.tmpdir) ++ self.root1 = os.path.join(self.tmpdir, "static1") ++ self.root2 = os.path.join(self.tmpdir, "static2") ++ self.outside = os.path.join(self.tmpdir, "outside") ++ for d in (self.root1, self.root2, self.outside): ++ os.mkdir(d) ++ with open(os.path.join(self.root1, "one.txt"), "w", encoding="utf-8") as f: ++ f.write("one") ++ with open(os.path.join(self.root2, "two.txt"), "w", encoding="utf-8") as f: ++ f.write("two") ++ with open(os.path.join(self.outside, "secret.txt"), "w", encoding="utf-8") as f: ++ f.write("secret") ++ os.symlink( ++ os.path.join(self.root1, "one.txt"), ++ os.path.join(self.root2, "internal.txt"), ++ ) ++ os.symlink( ++ os.path.join(self.outside, "secret.txt"), ++ os.path.join(self.root1, "link.txt"), ++ ) ++ super().setUp() ++ ++ def get_handlers(self): ++ return [ ++ ( ++ "/multi/(.*)", ++ MultiRootStaticFileHandler, ++ dict(path=[self.root1, self.root2]), ++ ) ++ ] ++ ++ def get_app_kwargs(self): ++ return dict() ++ ++ def test_first_root(self): ++ response = self.fetch("/multi/one.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"one") ++ ++ def test_second_root(self): ++ response = self.fetch("/multi/two.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"two") ++ ++ def test_symlink_to_other_root_rejected(self): ++ # Each root is validated on its own: a link from one search directory ++ # into another is still an escape from the directory it lives in. ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ response = self.fetch("/multi/internal.txt") ++ self.assertEqual(response.code, 403) ++ ++ def test_symlink_escaping_roots_rejected(self): ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ response = self.fetch("/multi/link.txt") ++ self.assertEqual(response.code, 403) ++ ++ + class StaticFileSymlinkedRootTest(WebTestCase): + """The root itself may be reached through a symlink; that must not make + every request look like an escape. +diff --git a/tornado/web.py b/tornado/web.py +index 2c8a1e1d..34315220 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -2794,6 +2794,13 @@ class StaticFileHandler(RequestHandler): + _static_hashes = {} # type: Dict[str, Optional[str]] + _lock = threading.Lock() # protects _static_hashes + ++ # None means "not set", in which case the symlink check falls back to the ++ # directory being served. The default lives on the class, not in ++ # initialize(), because this attribute was introduced in a security patch ++ # and some projects (notably Jupyter) subclass StaticFileHandler with their ++ # own initialize() that does not call ours. ++ allowed_symlink_directory = None # type: Optional[str] ++ + def initialize( + self, + path: str, +@@ -2802,8 +2809,6 @@ class StaticFileHandler(RequestHandler): + ) -> None: + self.root = path + self.default_filename = default_filename +- if allowed_symlink_directory is None: +- allowed_symlink_directory = path + self.allowed_symlink_directory = allowed_symlink_directory + + @classmethod +@@ -3016,6 +3021,11 @@ class StaticFileHandler(RequestHandler): + # the requested path so a request to root/ will match. + if not (absolute_path + os.path.sep).startswith(root): + raise HTTPError(403, "%s is not in root static directory", self.path) ++ # Symlinks may point anywhere inside allowed_symlink_directory, which ++ # defaults to the directory being served. Resolve that default here, ++ # against the same root the checks above used, rather than in ++ # _resolve_symlink_target. ++ allowed_symlink_directory = self.allowed_symlink_directory or root + # The check above is on the path as written, which is cheap and + # rejects traversal without touching the filesystem. Resolve + # symlinks only once it has passed, so that the remaining checks - +@@ -3024,7 +3034,9 @@ class StaticFileHandler(RequestHandler): + # cannot be merged: this one must stay on the unresolved path, + # because allowed_symlink_directory may be wider than the root and + # would then let a ../ traversal through. +- absolute_path = self._resolve_symlink_target(absolute_path) ++ absolute_path = self._resolve_symlink_target( ++ absolute_path, allowed_symlink_directory, self.path ++ ) + if os.path.isdir(absolute_path) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already +@@ -3049,7 +3061,9 @@ class StaticFileHandler(RequestHandler): + # on a path that has already escaped, or the difference between + # a redirect and a 403 would reveal whether an out-of-bounds + # directory exists. +- absolute_path = self._resolve_symlink_target(absolute_path) ++ absolute_path = self._resolve_symlink_target( ++ absolute_path, allowed_symlink_directory, self.path ++ ) + # Stat the resolved path once and keep the result, instead of + # letting os.path.exists, os.path.isfile and the later header + # generation each resolve the path again. Every one of those is a +@@ -3066,15 +3080,23 @@ class StaticFileHandler(RequestHandler): + self._stat_result = stat_result + return absolute_path + +- def _resolve_symlink_target(self, absolute_path: str) -> str: ++ @staticmethod ++ def _resolve_symlink_target( ++ absolute_path: str, allowed_symlink_directory: str, path_message: str ++ ) -> str: + """Resolves symlinks in ``absolute_path`` and validates the result. + + The path checks in `validate_absolute_path` operate on the path as + written, which says nothing about where a symlink points: a symlink + inside the static directory can name any file on the system. The + resolved path must therefore stay inside +- ``allowed_symlink_directory``, which defaults to the directory being +- served. ++ ``allowed_symlink_directory``. ++ ++ This is a static method so that it can only see the values its ++ caller checked against: ``allowed_symlink_directory`` is the ++ directory `validate_absolute_path` settled on, which is not ++ necessarily ``self.allowed_symlink_directory`` or ``self.root``. ++ ``path_message`` is used only to build the error message. + + Raises `HTTPError` (403) if the resolved path escapes that directory. + +@@ -3089,7 +3111,7 @@ class StaticFileHandler(RequestHandler): + # may itself be reached through a symlink (a static directory of + # /var/www that links to /srv/www, say). Comparing a resolved path + # against an unresolved directory would reject every request. +- allowed_directory = os.path.realpath(self.allowed_symlink_directory) ++ allowed_directory = os.path.realpath(allowed_symlink_directory) + if not allowed_directory.endswith(os.path.sep): + # As in validate_absolute_path, the separator must not be + # doubled when the directory is the filesystem root. +@@ -3105,7 +3127,7 @@ class StaticFileHandler(RequestHandler): + # Deliberately the same error as the check on the path as + # written, so that a caller cannot tell which way a path left + # the served directory. +- raise HTTPError(403, "%s is not in root static directory", self.path) ++ raise HTTPError(403, "%s is not in root static directory", path_message) + return resolved_path + + @classmethod +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263.patch new file mode 100644 index 0000000000..295f7e00e6 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263.patch @@ -0,0 +1,422 @@ +From 437ab5f76565403b3403438d2e555d11f8128d32 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Sun, 13 Sep 2026 03:42:20 +0000 +Subject: [PATCH] web: Do not follow symlinks out of the static directory + +StaticFileHandler.validate_absolute_path checked the requested path +against the configured root using os.path.abspath, which does not +resolve symlinks. A symlink inside the static directory could therefore +point anywhere on the filesystem and its contents would be served: both +a symlinked file and a symlinked directory were confirmed to expose +files outside the root. + +Resolve the path with os.path.realpath and require the result to remain +inside a new allowed_symlink_directory, which defaults to the served +path. Deployments that use such symlinks deliberately can widen it to a +directory containing all of the intended targets. Both this check and +the existing one on the path as written raise the same error, so a +caller cannot tell which way a path left the served directory. + +The two checks are not merged, and the resolution stays after the check +on the unresolved path. That check must operate on the path as written: +when allowed_symlink_directory is wider than the root, comparing the +resolved path instead would let a ../ traversal out of the root while +rejecting the deliberate symlinks the setting exists to permit. Doing +the cheap string comparison first also avoids walking the filesystem +for a path that is already out of bounds. + +Because default_filename is appended after the path has been resolved, +the joined path is resolved again: the directory containing an index +file may be legitimate while the index file itself is a symlink +pointing elsewhere. Deferring to a single resolution after that block +would be cheaper, but the isdir() check and the redirect it can trigger +would then run on a path that had already escaped, and the difference +between a 301 and a 403 would reveal whether an out-of-bounds directory +exists. A test pins this. The second resolution only happens for +directory-index requests; a request for a file resolves once. + +realpath() is applied to the allowed directory as well as to the +requested path, so that serving a directory that is itself reached +through a symlink continues to work instead of rejecting every request. +strict=True is not used: it is unavailable on Python 3.9, and a path +that does not exist resolves to itself and is then reported as a 404. + +This check reflects the filesystem at one instant, and a path swapped +for a symlink afterwards would not be caught; closing that race fully +would mean holding descriptors across the whole operation. Short of +that, the validated path is now stat'ed once and the result reused for +the Content-Length and Last-Modified headers, replacing the separate +os.path.exists and os.path.isfile calls. That cuts the number of times +a single request resolves the path from ten to six, and ensures the +headers describe the file that was validated. The residual race is +documented rather than papered over. + +CVE: CVE-2026-103263 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/437ab5f76565403b3403438d2e555d11f8128d32] + +Co-Authored-By: Claude Opus 5 +Claude-Session: https://claude.ai/code/session_01JD7LUuFMdyPJB1Dwdq6fPg +Signed-off-by: Yogita Urade +--- + tornado/test/web_test.py | 170 +++++++++++++++++++++++++++++++++++++++ + tornado/web.py | 100 ++++++++++++++++++++++- + 2 files changed, 267 insertions(+), 3 deletions(-) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 27e7fcfe..8a014d22 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -54,7 +54,9 @@ import itertools + import logging + import os + import re ++import shutil + import socket ++import tempfile + import typing # noqa: F401 + import unittest + import urllib.parse +@@ -1510,6 +1512,14 @@ class StaticFileTest(WebTestCase): + response = self.get_and_head("/static/blarg") + self.assertEqual(response.code, 404) + ++ def test_static_directory_without_default_filename(self): ++ # Without a default_filename, a directory is not servable. This ++ # shares the stat() that validate_absolute_path performs, so it is ++ # worth pinning the status it produces. ++ with ExpectLog(gen_log, ".*is not a file"): ++ response = self.get_and_head("/static/dir/") ++ self.assertEqual(response.code, 403) ++ + def test_path_traversal_protection(self): + # curl_httpclient processes ".." on the client side, so we + # must test this with simple_httpclient. +@@ -1536,6 +1546,166 @@ class StaticFileTest(WebTestCase): + self.assertEqual(response.code, 200) + + ++@unittest.skipIf(os.name != "posix", "non-posix OS") ++class StaticFileSymlinkTest(WebTestCase): ++ """Symlinks must not be followed out of the static directory unless the ++ application opts in with ``allowed_symlink_directory``. ++ """ ++ ++ def setUp(self): ++ self.tmpdir = tempfile.mkdtemp() ++ self.addCleanup(shutil.rmtree, self.tmpdir) ++ self.root = os.path.join(self.tmpdir, "static") ++ self.outside = os.path.join(self.tmpdir, "outside") ++ os.mkdir(self.root) ++ os.mkdir(self.outside) ++ with open(os.path.join(self.root, "inside.txt"), "w") as f: ++ f.write("inside") ++ with open(os.path.join(self.outside, "secret.txt"), "w") as f: ++ f.write("secret") ++ with open(os.path.join(self.root, "index.html"), "w") as f: ++ f.write("root index") ++ # A symlink to a file outside the root. ++ os.symlink( ++ os.path.join(self.outside, "secret.txt"), ++ os.path.join(self.root, "link.txt"), ++ ) ++ # A symlink to a directory outside the root. ++ os.symlink(self.outside, os.path.join(self.root, "linkdir")) ++ # Out-of-bounds symlink targets, one existing and one not, for the ++ # existence-oracle test below. ++ os.mkdir(os.path.join(self.outside, "realdir")) ++ os.symlink( ++ os.path.join(self.outside, "realdir"), ++ os.path.join(self.root, "link_exists"), ++ ) ++ os.symlink( ++ os.path.join(self.outside, "nope"), ++ os.path.join(self.root, "link_missing"), ++ ) ++ # A symlink that stays inside the root. ++ os.symlink( ++ os.path.join(self.root, "inside.txt"), ++ os.path.join(self.root, "internal.txt"), ++ ) ++ super().setUp() ++ ++ def get_handlers(self): ++ return [ ++ ("/default/(.*)", StaticFileHandler, dict(path=self.root)), ++ ( ++ "/permissive/(.*)", ++ StaticFileHandler, ++ dict(path=self.root, allowed_symlink_directory=self.tmpdir), ++ ), ++ ( ++ "/default_filename/(.*)", ++ StaticFileHandler, ++ dict(path=self.root, default_filename="index.html"), ++ ), ++ ] ++ ++ def get_app_kwargs(self): ++ return dict() ++ ++ def test_regular_file(self): ++ response = self.fetch("/default/inside.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"inside") ++ ++ def test_symlink_within_root(self): ++ # A symlink is fine as long as it resolves inside the root. ++ response = self.fetch("/default/internal.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"inside") ++ ++ def test_symlinked_file_escaping_root(self): ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ response = self.fetch("/default/link.txt") ++ self.assertEqual(response.code, 403) ++ ++ def test_symlinked_directory_escaping_root(self): ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ response = self.fetch("/default/linkdir/secret.txt") ++ self.assertEqual(response.code, 403) ++ ++ def test_served_directory_itself(self): ++ # A request for the served directory itself resolves to the allowed ++ # directory exactly, rather than to something beneath it. That must ++ # still be allowed. ++ response = self.fetch("/default_filename/") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"root index") ++ ++ def test_symlinked_default_filename(self): ++ # The default filename is appended after the directory has been ++ # resolved, so it must be checked in its own right. ++ os.mkdir(os.path.join(self.root, "dir")) ++ os.symlink( ++ os.path.join(self.outside, "secret.txt"), ++ os.path.join(self.root, "dir", "index.html"), ++ ) ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ response = self.fetch("/default_filename/dir/") ++ self.assertEqual(response.code, 403) ++ ++ def test_no_existence_oracle_for_symlinked_directory(self): ++ # A symlink pointing out of bounds must be rejected identically ++ # whether or not its target exists, so that it cannot be used to ++ # probe the server's filesystem. This pins the ordering in ++ # validate_absolute_path: resolving only after the isdir() check ++ # would let an existing directory trigger a redirect (301) while a ++ # missing one still gave 403. ++ codes = [] ++ for name in ("link_exists", "link_missing"): ++ with ExpectLog(gen_log, ".*is not in root static directory"): ++ codes.append( ++ self.fetch("/default_filename/" + name, follow_redirects=False).code ++ ) ++ self.assertEqual(codes, [403, 403]) ++ ++ def test_allowed_symlink_directory(self): ++ # With an explicit allowed_symlink_directory that contains the ++ # target, the same symlinks are served. ++ response = self.fetch("/permissive/link.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"secret") ++ ++ response = self.fetch("/permissive/linkdir/secret.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"secret") ++ ++ ++class StaticFileSymlinkedRootTest(WebTestCase): ++ """The root itself may be reached through a symlink; that must not make ++ every request look like an escape. ++ """ ++ ++ def setUp(self): ++ self.tmpdir = tempfile.mkdtemp() ++ self.addCleanup(shutil.rmtree, self.tmpdir) ++ real_root = os.path.join(self.tmpdir, "real") ++ os.mkdir(real_root) ++ with open(os.path.join(real_root, "inside.txt"), "w") as f: ++ f.write("inside") ++ # Serve through a symlink to the real directory. ++ self.root = os.path.join(self.tmpdir, "link") ++ os.symlink(real_root, self.root) ++ super().setUp() ++ ++ def get_handlers(self): ++ return [("/static/(.*)", StaticFileHandler, dict(path=self.root))] ++ ++ def get_app_kwargs(self): ++ return dict() ++ ++ @unittest.skipIf(os.name != "posix", "non-posix OS") ++ def test_symlinked_root(self): ++ response = self.fetch("/static/inside.txt") ++ self.assertEqual(response.code, 200) ++ self.assertEqual(response.body, b"inside") ++ ++ + class StaticDefaultFilenameTest(WebTestCase): + def get_app_kwargs(self): + return dict( +diff --git a/tornado/web.py b/tornado/web.py +index 6f1ae304..2c8a1e1d 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -72,6 +72,7 @@ import numbers + import os.path + import re + import socket ++import stat + import sys + import threading + import time +@@ -2736,6 +2737,13 @@ class StaticFileHandler(RequestHandler): + in your application settings, or add ``default_filename`` as an initializer + argument for your ``StaticFileHandler``. + ++ Symlinks are not followed out of the directory being served: if a path ++ resolves to a location outside it, the request is rejected with a 403 ++ error. Deployments that deliberately serve content through symlinks ++ pointing elsewhere can set the ``allowed_symlink_directory`` initializer ++ argument to a directory containing all of the intended targets. It ++ defaults to the ``path`` argument. ++ + To maximize the effectiveness of browser caching, this class supports + versioned urls (by default using the argument ``?v=``). If a version + is given, we instruct the browser to cache this file indefinitely. +@@ -2774,6 +2782,11 @@ class StaticFileHandler(RequestHandler): + + .. versionchanged:: 3.1 + Many of the methods for subclasses were added in Tornado 3.1. ++ ++ .. versionchanged:: 6.5.9 ++ Symlinks pointing outside the served directory are no longer ++ followed, and the ``allowed_symlink_directory`` argument was added ++ to configure this. + """ + + CACHE_MAX_AGE = 86400 * 365 * 10 # 10 years +@@ -2781,9 +2794,17 @@ class StaticFileHandler(RequestHandler): + _static_hashes = {} # type: Dict[str, Optional[str]] + _lock = threading.Lock() # protects _static_hashes + +- def initialize(self, path: str, default_filename: Optional[str] = None) -> None: ++ def initialize( ++ self, ++ path: str, ++ default_filename: Optional[str] = None, ++ allowed_symlink_directory: Optional[str] = None, ++ ) -> None: + self.root = path + self.default_filename = default_filename ++ if allowed_symlink_directory is None: ++ allowed_symlink_directory = path ++ self.allowed_symlink_directory = allowed_symlink_directory + + @classmethod + def reset(cls) -> None: +@@ -2995,6 +3016,15 @@ class StaticFileHandler(RequestHandler): + # the requested path so a request to root/ will match. + if not (absolute_path + os.path.sep).startswith(root): + raise HTTPError(403, "%s is not in root static directory", self.path) ++ # The check above is on the path as written, which is cheap and ++ # rejects traversal without touching the filesystem. Resolve ++ # symlinks only once it has passed, so that the remaining checks - ++ # and the path this method returns - refer to the file that will ++ # actually be opened rather than to the link. The two checks ++ # cannot be merged: this one must stay on the unresolved path, ++ # because allowed_symlink_directory may be wider than the root and ++ # would then let a ../ traversal through. ++ absolute_path = self._resolve_symlink_target(absolute_path) + if os.path.isdir(absolute_path) and self.default_filename is not None: + # need to look at the request.path here for when path is empty + # but there is some prefix to the path that was already +@@ -3012,12 +3042,72 @@ class StaticFileHandler(RequestHandler): + self.redirect(self.request.path + "/", permanent=True) + return None + absolute_path = os.path.join(absolute_path, self.default_filename) +- if not os.path.exists(absolute_path): ++ # The default filename may be a symlink even when the directory ++ # containing it is not. Resolving here rather than deferring a ++ # single resolution until after this block is deliberate: the ++ # isdir() above, and the redirect it can trigger, must not run ++ # on a path that has already escaped, or the difference between ++ # a redirect and a 403 would reveal whether an out-of-bounds ++ # directory exists. ++ absolute_path = self._resolve_symlink_target(absolute_path) ++ # Stat the resolved path once and keep the result, instead of ++ # letting os.path.exists, os.path.isfile and the later header ++ # generation each resolve the path again. Every one of those is a ++ # separate trip through the filesystem that could observe a ++ # different file from the one validated here. ++ try: ++ stat_result = os.stat(absolute_path) ++ except OSError: ++ # Matches the previous os.path.exists() check, which is also ++ # false when the path cannot be stat'ed at all. + raise HTTPError(404) +- if not os.path.isfile(absolute_path): ++ if not stat.S_ISREG(stat_result.st_mode): + raise HTTPError(403, "%s is not a file", self.path) ++ self._stat_result = stat_result + return absolute_path + ++ def _resolve_symlink_target(self, absolute_path: str) -> str: ++ """Resolves symlinks in ``absolute_path`` and validates the result. ++ ++ The path checks in `validate_absolute_path` operate on the path as ++ written, which says nothing about where a symlink points: a symlink ++ inside the static directory can name any file on the system. The ++ resolved path must therefore stay inside ++ ``allowed_symlink_directory``, which defaults to the directory being ++ served. ++ ++ Raises `HTTPError` (403) if the resolved path escapes that directory. ++ ++ Note that this is a check against the state of the filesystem at ++ one moment: a path that is replaced by a symlink after this returns ++ but before the file is opened would not be caught. Closing that ++ race entirely would require holding file descriptors across the ++ whole operation. This check is not a substitute for filesystem ++ permissions on a directory that untrusted users can write to. ++ """ ++ # realpath() is applied to the allowed directory as well, because it ++ # may itself be reached through a symlink (a static directory of ++ # /var/www that links to /srv/www, say). Comparing a resolved path ++ # against an unresolved directory would reject every request. ++ allowed_directory = os.path.realpath(self.allowed_symlink_directory) ++ if not allowed_directory.endswith(os.path.sep): ++ # As in validate_absolute_path, the separator must not be ++ # doubled when the directory is the filesystem root. ++ allowed_directory += os.path.sep ++ # strict=True is deliberately not used here: it is unavailable on ++ # Python 3.9, and a path that does not exist resolves to itself, ++ # which the caller then reports as a 404. ++ resolved_path = os.path.realpath(absolute_path) ++ # The trailing separator lets the allowed directory itself match, as ++ # well as anything beneath it. This is reachable because the path may ++ # still be a directory at this point (when default_filename is set). ++ if not (resolved_path + os.path.sep).startswith(allowed_directory): ++ # Deliberately the same error as the check on the path as ++ # written, so that a caller cannot tell which way a path left ++ # the served directory. ++ raise HTTPError(403, "%s is not in root static directory", self.path) ++ return resolved_path ++ + @classmethod + def get_content( + cls, abspath: str, start: Optional[int] = None, end: Optional[int] = None +@@ -3077,6 +3167,10 @@ class StaticFileHandler(RequestHandler): + + def _stat(self) -> os.stat_result: + assert self.absolute_path is not None ++ # validate_absolute_path normally populates this, so that the size ++ # and modification time reported here describe the same file that ++ # was validated. Subclasses that override validate_absolute_path ++ # without calling super() fall back to stat'ing here. + if not hasattr(self, "_stat_result"): + self._stat_result = os.stat(self.absolute_path) + return self._stat_result +-- +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 2888090884..a84503db51 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 @@ -13,6 +13,8 @@ SRC_URI += "file://CVE-2026-91990.patch \ file://CVE-2026-91991.patch \ file://CVE-2026-103261.patch \ file://CVE-2026-103262.patch \ + file://CVE-2026-103263.patch \ + file://CVE-2026-103263-dependent.patch \ " inherit pypi python_setuptools_build_meta From patchwork Thu Nov 5 07:08:17 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102991 Received: from rcdn-iport-7.cisco.com (rcdn-iport-7.cisco.com [173.37.86.78]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28859.1793862502181832854 for ; Wed, 04 Nov 2026 23:08:22 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=ekYLDW8x; spf=pass (domain: cisco.com, ip: 173.37.86.78, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=16524; q=dns/txt; s=iport01; t=1793862502; x=1795072102; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=ghknYZT/LCwnuWcdot9v2ImszkDdw0fsl3TXc/tiLso=; b=ekYLDW8xbsqzTBUhHD0oQhT2it1Uo5XoqtsnDaSaGpI1lrpabEVEPCdH r6OHMryPkXy7Hyhoqs3dNkD8GSqjoxWzOZVZ3k62CU3hrtkGlKfLag1jJ f8I32ywMj1dTlltriP0FsJrgdx1K80tVI10MktS+nLv+JMCKNh3Jxm4f8 lyDTXwHdDTl2iZuah7RgBHO04fu82yEVJC99q+iq5kkqtjhu5iGb50o+9 Qbw6Hj9FbPed5DlpBriT+FaByMqoaFPoIGEpriuqpR3KJCrPo+dUeA+Sq Y6nkbhkUXD2QsaUGJ50tVF8gbVm8Ki/glBkHC5rHNR398B7qLbadUCPK9 g==; X-CSE-ConnectionGUID: xP+CFfc+RwuJFEKuHicNTA== X-CSE-MsgGUID: Nc1oWQlwRc+PmR7aLKMnVQ== X-IPAS-Result: A0BLAgC/Kexq/4//Ja1aglmCGD91YBopSQOUJ4IhA5FMjFWBfg8BAQEPRA0EAQGEP0YCjh0CJjQJDgECBAMCAwEBAQEBAQEBAQEBAQoBAQUBAQECAQcFgQ4ThhUIMg2QEgECAQMnCwEYAT0cAwECLysjCBmCKlgBgnQDEbw+gXkzgQGDKQE/AkNQ2UuBaAELFAGBOIVAiCNdGAGEfScbgUlEgRWDaYEFgVwBAQIBiCMEgg2BIYFagQOFPYZYAoFShnFIgR4DWSwBVRMNCgsHBYFoAzUSKhVuMh2BIz4XgQcbBgWBHYEegRKBB12BKyheIYEIgXYCglGCAAIBSUEOB0VTCSVDEkckIggSCQETGiwKgRo1PAkmPxUMZAsaBWEIAg0McjkQMB4kLwUcJkAbChgYDUgRLDcVGQQ+bgeSL4JxIBY1FgIrASkBARc/Gw4GGy0bRAdEA5JDJBSSLaERhCqMJI9ChXgaM4QEgVeSQpJUC5h9jgqVYSADC0KEaYFoPIFHCwczGggbFTuCZwlKGQ+DOIpzDguBaIF4gX+CCIEMUch3JjICAQgDLwEBBwIHDgMLgWiRfgEB IronPort-Data: A9a23:KJM/BK6/nC6pqCnlPpsJoAxRtG7GchMFZxGqfqrLsTDasY5as4F+v jRKXWrXbq6ONGfyeIx1YNngoUwFsZDSnNBiSQc9pSFgZn8b8sCt6fZ1gavT04J+CuWZESqLO u1HMoGowPgcFyGa+1H1dOex9RGQ7InQLpLkEunIJyttcgFtTSYlmHpLlvUw6mJSqYDR7zil5 5Wo/KUzBHf/g2QqaztMt/rZwP9SlK2aVA0w7wRWic9j5Dcyp1FNZLoDKKe4KWfPQ4U8NoaSW +bZwbilyXjS9hErB8nNuu6TnpoiG+O60aCm0xK6aoD66vRwjnVaPpUTaJLwXXxqZwChxLid/ jniWauYEm/FNoWU8AgUvoIx/ytWZcWq85efSZSzXFD6I0DuKxPRL/tS4E4eLbVb0ch5CiJ06 uE6Bh0uczCCjMSK6efuIgVsrpxLwMjDJogTvDRkiDreF/tjGc6FSKTR7tge1zA17ixMNa+BP IxCNnw1MUmGOkEUUrsUIMpWcOOAl2TlejFVgFmUvqEwpWPUyWSd1ZCwbIGEIo3TGJg9ckCwg Gmf+FvABg4gP5+nzADa+1aPo+nxpHauMG4VPPjinhJwu3WU3mEVBRgcWFe3rPX8hkOgVtZ3L 00P5jFoqrA/8kGuRNTxUxC05nmesXYht8F4CeY27kSJj6HT+QvcXjZCRT9aY9tgv8gzLdA36 mK0cxrSLWQHmNWopbi1rN94cRva1fApEFI/ IronPort-HdrOrdr: A9a23:49NdsalJAwyjd3+g0ZmOB1ujCxHpDfIA3DAbv31ZSRFFG/Fw8P re+MjzuiWbtN98YhwdcJW7Scq9qBDnhPtICPcqXItKNTOO0ADDEGgh1/qB/9SKIULDH4BmuZ uIC5IfNPTASX5nkM39/A60V/wkwNWB7eSUoN229QYLcemvAJsQljuQzW2gYytLeDU= X-Talos-CUID: 9a23:sdjnc2B0IBHEAVL6Ezlft2U6FMtmSGLA7ln9AFKYCG1PdJTAHA== X-Talos-MUID: 9a23:WkBe0QZJrRl6xOBTkTS1gw5bMNhS6OeWDXpSmLElns+6DHkl X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="543909155" Received: from rcdn-l-core-06.cisco.com ([173.37.255.143]) by rcdn-iport-7.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-06.cisco.com (Postfix) with ESMTPS id 141B618000268 for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id AFDC1CC124A; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 4/6] python3-tornado: Fix CVE-2026-115927 Date: Wed, 4 Nov 2026 23:08:17 -0800 Message-Id: <20261105070819.2086816-5-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-06.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:29 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131864 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 --- .../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 --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 +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 +Claude-Session: https://claude.ai/code/session_019XKZM6XMVNYGibRyDU6QZW +Signed-off-by: Yogita Urade +--- + 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 From patchwork Thu Nov 5 07:08:18 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102986 Received: from rcdn-iport-9.cisco.com (rcdn-iport-9.cisco.com [173.37.86.80]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28858.1793862502132422203 for ; Wed, 04 Nov 2026 23:08:22 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=W7dWv6lm; spf=pass (domain: cisco.com, ip: 173.37.86.80, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=19146; q=dns/txt; s=iport01; t=1793862502; x=1795072102; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=BSaQJSS78OJSvYNV9R4cNRP5oC45hU8L4MEAE8NBD0s=; b=W7dWv6lmUW6e7AjG+P2QqNBhGAw7BAifmhOqeWxpj6+7G/PFe0kfoZVx SQxN6MrCYxjjGRcPO3l5AYU539/G0LITYUDbLoiSFV033/pmQ87uXleD4 bCFPaUXMwkaPwNBOxTNnweas0QdRPc8Ea70soVfph5s2RqomAHBYZVz2P Z5IK+ENNjDm0Cs358GpO6s2O0Lm3ei8wBRYQ93B/fgKH0qBkAGeWc6k35 jgdB53do29eu/OPERbpiPOaERuxy+EeAGTv1SE/PvfOrKuDCiz4HxZpBm aFtm393Owh3ljMtzmoCfPJYlLXBA298wg56ofpuX1E53Q1YbFdTgJ17CI Q==; X-CSE-ConnectionGUID: 3TxpuU/GSjWgFL1iNEwFpA== X-CSE-MsgGUID: H5OU1aA1TGWhWK2qmV6riA== X-IPAS-Result: A0AaAABGKuxq/4z/Ja1aHAEBAQEBAQcBARIBAQQEAQGBfAcBAQsBglZ1YENJjHKHOIIhA4ETkg2LARSBag8BAQEPRA0EAQGEP0YCjh0CJjQJDgECBAMCAwEBAQEBAQEBAQEBAQoBAQUBAQECAQcFgQ4Thk8NkBIBAgEDJwsBGAE9HAMBAi8rIwgZgwIBgnQDEQa8OIF5M4EBgykBPwJDUNlLgWgBCxQBgTgBhT+II10YAUSEOScbgUlEgRQBgnN2gQWBJjYBAQKBRECGIASCDYEhgVoeZYIiiXMCgVKGcUiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEdgR6BEoEHXYErKF4hgQiBdgKCUYIAAgFJQQ4HRVMJJUMSRyQiCBIJARMaLAqBGjU8CSY/FQxkCxoFYQgCDQxyORAwHiQvBRwmQBsKGBgNSBEsNxUZBD5uB5Ivgh5TGQcBDR8EShMBCh8BAQQTPyUEBBU/JgcBJw8CCx4UknEyj2SCIYE1n1yEKowkj0KFeBozhASBV6UWC5h9glmLMZYBUIRpgWg8gUcLBzMaCBsVO4JnCUoZD44uCwuBaIF4gX9agjpRyHcmMgIBCDIBAQcCBw4DC4FokR5gAQE IronPort-Data: A9a23:PD/JhakRpcfqdVdokp2g6Nbo5gzXJ0RdPkR7XQ2eYbSJt1+Wr1Gzt xIXW2mEb/uOa2WhLYsnbdvkphgPscDXyINrSgFqq38wH1tH+JHPbTi7wugcHM8zwunrFh8PA xA2M4GYRCwMZiaC4Errav6+/SEUOZigHtLUEPTDNj16WThqQSIgjQMLs+Mii+aEu/Dha++2k Y20+ZK31GONgWYubDtNs/7b8nuDgdyr0N8mlg1mDRx0lAe2e0k9VPo3Oay3Jn3kdYhYdsbSb /rD1ryw4lTC9B4rDN6/+p6jGqHdauePVeQmoiM+t5mK2nCulARrukoIHKZ0hXNsttm8t4sZJ OOhGnCHYVxB0qXkwIzxWvTDes10FfUuFLTveRBTvSEPpqHLWyOE/hlgMK05FdYk6v5qPCZhz v4nJx4pNxGlqv2Y2JvuH4GAhux7RCXqFJkUtnclyXTSCuwrBMiYBa7L/tRfmjw3g6iiH96HO JFfMmUpNkmdJUQTYj/7C7pm9AusrmjiaTRUpXqepLE85C7YywkZPL3FbYaOJIfQFJoL9qqej kKb2FmgCzhDCPOgyQWmqGupuLHVnAquDer+E5X9rJaGmma7wXQeDhATX1a3rfS1z0KzRd9bA 0gV4TY1668q+UqmS9PwUxG1rDiDpBF0ZjZLO/cx5AfIzu/f5ByUQzBfCDVAc9ch8sQxQFTGy 2O0oj8gPhQ32JX9dJ5X3u78Qe+aUcTNEVI/WA== IronPort-HdrOrdr: A9a23:f8b+n6o0+tAz2RV6dlGzDjAaV5oHeYIsimQD101hICG9Ffbo8/ xG88506faZslsssTQb6LO90cq7MBbhHOBOgLX5VI3KNGKNhILrFvAB0WKI+VLd8kPFmtK1rZ 0BT4FOTPvtEFN9kcH2pCO8E9om3Z271ZrAv5a485+oJjsaEp2JKGxCe2CmLnE= X-Talos-CUID: 9a23:HeeSPGkw91NGfnEpl9fmN2RceI/XOSbh3G/NHgiXMmRWR5OxEF621bxKi+M7zg== X-Talos-MUID: 9a23:dhaIQwx8rWqc9Q0WePajlXSNROqaqITzL31UmLxfh+a7NRNTYS6YsmqtQ6Zyfw== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="543632976" Received: from rcdn-l-core-03.cisco.com ([173.37.255.140]) by rcdn-iport-9.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-03.cisco.com (Postfix) with ESMTPS id 61D48180001EB for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id B465ECC124B; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 5/6] python3-tornado: Fix CVE-2026-116577 Date: Wed, 4 Nov 2026 23:08:18 -0800 Message-Id: <20261105070819.2086816-6-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-03.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:28 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131865 This patch applies the upstream fix as referenced in [4], using the commit shown in [1], [2] and [3]. [1] https://github.com/tornadoweb/tornado/commit/1b7a1dc9f340f52de929852e8125aa12f25297c9 [2] https://github.com/tornadoweb/tornado/commit/09591f65286a600c2196df9c33b792b889a6bccb [3] https://github.com/tornadoweb/tornado/commit/3ef5ec3fc7a158ecd0ca7bfc5fcee42fa2773094 [4] https://github.com/tornadoweb/tornado/pull/3765 [5] https://nvd.nist.gov/vuln/detail/CVE-2026-116577 Signed-off-by: Yogita Urade --- .../python3-tornado/CVE-2026-116577_p1.patch | 195 ++++++++++++++++++ .../python3-tornado/CVE-2026-116577_p2.patch | 99 +++++++++ .../python3-tornado/CVE-2026-116577_p3.patch | 101 +++++++++ .../python/python3-tornado_6.5.7.bb | 3 + 4 files changed, 398 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p2.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p3.patch diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p1.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p1.patch new file mode 100644 index 0000000000..0c4db00069 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p1.patch @@ -0,0 +1,195 @@ +From 1b7a1dc9f340f52de929852e8125aa12f25297c9 Mon Sep 17 00:00:00 2001 +From: Ben Darnell +Date: Tue, 6 Oct 2026 09:59:02 -0400 +Subject: [PATCH] httputil: Remove support for "obsolete line folding" + +This optional part of the HTTP specification is no longer used +in practice, and continuing to support it adds complexity and risk +of DoS via quadratic performance on malicious data. + +CVE: CVE-2026-116577 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/1b7a1dc9f340f52de929852e8125aa12f25297c9] + +Backport Changes: +- Kept unittest.TestCase for HTTPHeadersTest because 6.5.7's httputil_test.py + does not import tornado.test.util.TestCase. +- Updated test_multi_line_headers to preserve folded-header acceptance for Curl + and expect a 599 response from simple_httpclient. +- The httputil_test.py hunk removes two extra target-version lines because + 6.5.7 formats the removed multiline .replace() expression across three lines. + +Signed-off-by: Yogita Urade +--- + tornado/httputil.py | 34 +++++++++--------------- + tornado/test/httpclient_test.py | 17 +++++++++++-- + tornado/test/httputil_test.py | 47 +++------------------------------ + 3 files changed, 29 insertions(+), 69 deletions(-) + +diff --git a/tornado/httputil.py b/tornado/httputil.py +index 6a5db233..45e1883d 100644 +--- a/tornado/httputil.py ++++ b/tornado/httputil.py +@@ -195,7 +195,6 @@ class HTTPHeaders(StrMutableMapping): + # on demand (and cleared whenever the list is modified). + self._as_list: dict[str, list[str]] = {} + self._combined_cache: dict[str, str] = {} +- self._last_key = None # type: Optional[str] + if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], HTTPHeaders): + # Copy constructor + for k, v in args[0].get_all(): +@@ -219,7 +218,6 @@ class HTTPHeaders(StrMutableMapping): + if _FORBIDDEN_HEADER_CHARS_RE.search(value): + raise HTTPInputError("Invalid header value %r" % value) + norm_name = _normalize_header(name) +- self._last_key = norm_name + if norm_name in self: + self._combined_cache.pop(norm_name, None) + self._as_list[norm_name].append(value) +@@ -260,6 +258,11 @@ class HTTPHeaders(StrMutableMapping): + In Tornado 7.0, certain deprecated features of HTTP will become errors. + Specifically, line folding and the use of LF (with CR) as a line separator + will be removed. ++ ++ .. versionchanged:: 6.6 ++ Support for line folding was removed due to security/DoS concerns. This removal ++ was ahead of the original removal schedule announced for Tornado 7.0. ++ Support for LF without CR remains until Tornado 7.0. + """ + if m := re.search(r"\r?\n$", line): + # RFC 9112 section 2.2: a recipient MAY recognize a single LF as a line +@@ -270,27 +273,14 @@ class HTTPHeaders(StrMutableMapping): + # Empty line, or the final CRLF of a header block. + return + if line[0] in HTTP_WHITESPACE: +- # continuation of a multi-line header +- # TODO(7.0): Remove support for line folding. +- if self._last_key is None: +- raise HTTPInputError("first header line cannot start with whitespace") +- new_part = " " + line.strip(HTTP_WHITESPACE) +- if _chars_are_bytes: +- if not _ABNF.field_value.fullmatch(new_part[1:]): +- raise HTTPInputError("Invalid header continuation %r" % new_part) +- else: +- if _FORBIDDEN_HEADER_CHARS_RE.search(new_part): +- raise HTTPInputError("Invalid header value %r" % new_part) +- self._as_list[self._last_key][-1] += new_part +- self._combined_cache.pop(self._last_key, None) +- else: +- try: +- name, value = line.split(":", 1) +- except ValueError: +- raise HTTPInputError("no colon in header line") +- self.add( +- name, value.strip(HTTP_WHITESPACE), _chars_are_bytes=_chars_are_bytes ++ raise HTTPInputError( ++ "header line cannot start with whitespace, folding not supported" + ) ++ try: ++ name, value = line.split(":", 1) ++ except ValueError: ++ raise HTTPInputError("no colon in header line") ++ self.add(name, value.strip(HTTP_WHITESPACE), _chars_are_bytes=_chars_are_bytes) + + @classmethod + def parse(cls, headers: str, *, _chars_are_bytes: bool = True) -> "HTTPHeaders": +diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py +index 46300200..4cb2e850 100644 +--- a/tornado/test/httpclient_test.py ++++ b/tornado/test/httpclient_test.py +@@ -734,10 +734,14 @@ def test_multi_line_headers(self): + % (resp.body, value, container), + ) + + def test_multi_line_headers(self): +- # Multi-line http headers are rare but rfc-allowed +- # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 ++ # Line folding is an obsolete way of representing headers split across multiple lines. ++ # https://www.rfc-editor.org/info/rfc9112/#section-5.2 ++ # ++ # They must either be folded into a single line or rejected. ++ # Tornado 6.6 removed support for line folding in simple_httpclient, but they are ++ # still supported in libcurl. + sock, port = bind_unused_port() + with closing(sock): + + @gen.coroutine +@@ -760,10 +764,15 @@ def test_multi_line_headers(self): + + netutil.add_accept_handler(sock, accept_callback) # type: ignore + try: + resp = self.fetch("http://127.0.0.1:%d/" % port) +- resp.rethrow() +- self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block") ++ if self.http_client.__class__.__module__ == "tornado.curl_httpclient": ++ resp.rethrow() ++ self.assertEqual( ++ resp.headers["X-XSS-Protection"], "1; mode=block" ++ ) ++ else: ++ self.assertEqual(resp.code, 599) + finally: + self.io_loop.remove_handler(sock.fileno()) + + @gen_test +diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py +index 0e9d5c41..40108d9c 100644 +--- a/tornado/test/httputil_test.py ++++ b/tornado/test/httputil_test.py +@@ -384,50 +384,11 @@ Content-Disposition: form-data; name="files"; filename="ab.txt" + + + class HTTPHeadersTest(unittest.TestCase): +- def test_multi_line(self): +- # Lines beginning with whitespace are appended to the previous line +- # with any leading whitespace replaced by a single space. +- # Note that while multi-line headers are a part of the HTTP spec, +- # their use is strongly discouraged. +- data = """\ +-Foo: bar +- baz +-Asdf: qwer +-\tzxcv +-Foo: even +- more +- lines +-""".replace( +- "\n", "\r\n" +- ) +- headers = HTTPHeaders.parse(data) +- self.assertEqual(headers["asdf"], "qwer zxcv") +- self.assertEqual(headers.get_list("asdf"), ["qwer zxcv"]) +- self.assertEqual(headers["Foo"], "bar baz,even more lines") +- self.assertEqual(headers.get_list("foo"), ["bar baz", "even more lines"]) +- self.assertEqual( +- sorted(list(headers.get_all())), +- [("Asdf", "qwer zxcv"), ("Foo", "bar baz"), ("Foo", "even more lines")], +- ) +- # Verify case insensitivity in-operator +- self.assertTrue("asdf" in headers) +- self.assertTrue("Asdf" in headers) +- +- def test_continuation(self): ++ def test_folding_rejected(self): + data = "Foo: bar\r\n\tasdf" +- headers = HTTPHeaders.parse(data) +- self.assertEqual(headers["Foo"], "bar asdf") +- +- # If the first line starts with whitespace, it's a +- # continuation line with nothing to continue, so reject it +- # (with a proper error). +- data = " Foo: bar" +- self.assertRaises(HTTPInputError, HTTPHeaders.parse, data) +- +- # \f (formfeed) is whitespace according to str.isspace, but +- # not according to the HTTP spec. +- data = "Foo: bar\r\n\fasdf" +- self.assertRaises(HTTPInputError, HTTPHeaders.parse, data) ++ with self.assertRaises(HTTPInputError): ++ # Folded continuation lines are not supported. ++ HTTPHeaders.parse(data) + + def test_forbidden_ascii_characters(self): + # Control characters and ASCII whitespace other than space, tab, and CRLF are not allowed in +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p2.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p2.patch new file mode 100644 index 0000000000..0d1a7e5732 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p2.patch @@ -0,0 +1,99 @@ +From 09591f65286a600c2196df9c33b792b889a6bccb Mon Sep 17 00:00:00 2001 +From: Claude +Date: Tue, 6 Oct 2026 14:14:19 +0000 +Subject: [PATCH] http1connection: Notify client delegate of header parse + errors + +Previously, an HTTPInputError raised while parsing response headers +happened before the delegate was marked as needing on_connection_close, +and close() cleared the stream's close callback, so simple_httpclient +never completed the request and it hung until the timeout. Now the +delegate is told the connection closed, matching the behavior for +errors raised after headers_received. + +Update test_multi_line_headers to expect an error from simple_httpclient +now that line folding is no longer supported (curl still accepts it). + +CVE: CVE-2026-116577 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/09591f65286a600c2196df9c33b792b889a6bccb] + +Backport Changes: +- Added a supports_line_folding flag to the shared test; Curl keeps the + acceptance check while Simple expects HTTPError for folded response headers. + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_016cNR4NyeuzQ1qFdqCk1yum +Signed-off-by: Yogita Urade +--- + tornado/http1connection.py | 5 +++++ + tornado/test/httpclient_test.py | 19 +++++++++++++------ + tornado/test/curl_httpclient_test.py | 2 ++ + 3 files changed, 20 insertions(+), 6 deletions(-) + +diff --git a/tornado/http1connection.py b/tornado/http1connection.py +--- a/tornado/http1connection.py ++++ b/tornado/http1connection.py +@@ -316,6 +316,11 @@ async def _read_message + gen_log.info("Malformed HTTP message from %s: %s", self.context, e) + if not self.is_client: + await self.stream.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") ++ elif not need_delegate_close: ++ # The error came before headers_received, so the delegate ++ # doesn't know about this message yet. Tell it the ++ # connection is closed so a client request doesn't hang. ++ need_delegate_close = True + self.close() + return False + finally: + +diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py +--- a/tornado/test/httpclient_test.py ++++ b/tornado/test/httpclient_test.py +@@ -263,6 +263,9 @@ class HTTPClientCommonTestCase + + + class HTTPClientCommonTestCase(AsyncHTTPTestCase): ++ # Set by subclasses whose client accepts obsolete line folding in ++ # response headers (see test_multi_line_headers). ++ supports_line_folding = False + # 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). +@@ -764,14 +765,18 @@ class HTTPClientCommonTestCase + + netutil.add_accept_handler(sock, accept_callback) # type: ignore + try: +- resp = self.fetch("http://127.0.0.1:%d/" % port) +- if self.http_client.__class__.__module__ == "tornado.curl_httpclient": ++ if self.supports_line_folding: ++ resp = self.fetch("http://127.0.0.1:%d/" % port) + resp.rethrow() +- self.assertEqual( +- resp.headers["X-XSS-Protection"], "1; mode=block" +- ) ++ self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block") + else: +- self.assertEqual(resp.code, 599) ++ with ExpectLog( ++ gen_log, "Malformed HTTP message.*folding", level=logging.INFO ++ ): ++ with self.assertRaises(HTTPError): ++ self.fetch( ++ "http://127.0.0.1:%d/" % port, raise_error=True ++ ) + finally: + self.io_loop.remove_handler(sock.fileno()) + + +diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py +--- a/tornado/test/curl_httpclient_test.py ++++ b/tornado/test/curl_httpclient_test.py +@@ -45,6 +45,8 @@ class CurlHTTPClientCommonTestCase + # 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 ++ # libcurl still accepts obsolete line folding in response headers. ++ supports_line_folding = True + + def get_http_client(self): + client = CurlAsyncHTTPClient(defaults=dict(allow_ipv6=False)) diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p3.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p3.patch new file mode 100644 index 0000000000..33670c88b5 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116577_p3.patch @@ -0,0 +1,101 @@ +From 3ef5ec3fc7a158ecd0ca7bfc5fcee42fa2773094 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Tue, 6 Oct 2026 15:28:59 +0000 +Subject: [PATCH] test: Accept either outcome in test_multi_line_headers + +Rather than declaring per-implementation whether line folding is +supported (which was wrong when --httpclient changes the default +client), accept either a correctly unfolded header or a 599 error. + +CVE: CVE-2026-116577 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/3ef5ec3fc7a158ecd0ca7bfc5fcee42fa2773094] + +Backport Changes: +- Rebased the test update on p2's 6.5.7-specific test body. Its fetch call and + error assertion use a different line layout, accounting for two additional + deleted lines in httpclient_test.py compared with upstream. + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_016cNR4NyeuzQ1qFdqCk1yum +Signed-off-by: Yogita Urade +--- + tornado/test/httpclient_test.py | 37 +++++++++++++++++----------------- + tornado/test/curl_httpclient_test.py | 2 -- + 2 files changed, 19 insertions(+), 20 deletions(-) + +diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py +index bb7de5c4..449f35a4 100644 +--- a/tornado/test/httpclient_test.py ++++ b/tornado/test/httpclient_test.py +@@ -263,9 +263,6 @@ class HTTPClientCommonTestCase(AsyncHTTPTestCase): + + + class HTTPClientCommonTestCase(AsyncHTTPTestCase): +- # Set by subclasses whose client accepts obsolete line folding in +- # response headers (see test_multi_line_headers). +- supports_line_folding = False + # 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). +@@ -741,9 +738,10 @@ def test_multi_line_headers(self): + # Line folding is an obsolete way of representing headers split across multiple lines. + # https://www.rfc-editor.org/info/rfc9112/#section-5.2 + # +- # They must either be folded into a single line or rejected. +- # Tornado 6.6 removed support for line folding in simple_httpclient, but they are +- # still supported in libcurl. ++ # The spec requires user agents to unfold them, but simple_httpclient ++ # rejects them as of Tornado 6.6 while libcurl still accepts them. Either ++ # outcome is acceptable here; what matters is that the client doesn't hang ++ # and doesn't misparse the header. + sock, port = bind_unused_port() + with closing(sock): + +@@ -767,18 +765,21 @@ def test_multi_line_headers(self): + + netutil.add_accept_handler(sock, accept_callback) # type: ignore + try: +- if self.supports_line_folding: +- resp = self.fetch("http://127.0.0.1:%d/" % port) +- resp.rethrow() +- self.assertEqual(resp.headers["X-XSS-Protection"], "1; mode=block") +- else: +- with ExpectLog( +- gen_log, "Malformed HTTP message.*folding", level=logging.INFO +- ): +- with self.assertRaises(HTTPError): +- self.fetch( +- "http://127.0.0.1:%d/" % port, raise_error=True +- ) ++ with ExpectLog( ++ gen_log, ++ "Malformed HTTP message.*folding", ++ level=logging.INFO, ++ required=False, ++ ): ++ try: ++ resp = self.fetch("http://127.0.0.1:%d/" % port) ++ except HTTPError as e: ++ self.assertEqual(e.code, 599) ++ else: ++ resp.rethrow() ++ self.assertEqual( ++ resp.headers["X-XSS-Protection"], "1; mode=block" ++ ) + finally: + self.io_loop.remove_handler(sock.fileno()) + + +diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py +index 7a53f206..d7eb1adf 100644 +--- a/tornado/test/curl_httpclient_test.py ++++ b/tornado/test/curl_httpclient_test.py +@@ -45,8 +45,6 @@ class CurlHTTPClientCommonTestCase + # 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 +- # libcurl still accepts obsolete line folding in response headers. +- supports_line_folding = True + + def get_http_client(self): + client = CurlAsyncHTTPClient(defaults=dict(allow_ipv6=False)) 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 1dd2ff3c32..87b6721320 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 @@ -16,6 +16,9 @@ SRC_URI += "file://CVE-2026-91990.patch \ file://CVE-2026-103263.patch \ file://CVE-2026-103263-dependent.patch \ file://CVE-2026-115927.patch \ + file://CVE-2026-116577_p1.patch \ + file://CVE-2026-116577_p2.patch \ + file://CVE-2026-116577_p3.patch \ " inherit pypi python_setuptools_build_meta From patchwork Thu Nov 5 07:08:19 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Yogita Urade X-Patchwork-Id: 102987 Received: from rcdn-iport-7.cisco.com (rcdn-iport-7.cisco.com [173.37.86.78]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.28859.1793862502181832854 for ; Wed, 04 Nov 2026 23:08:22 -0800 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=cZikn5h3; spf=pass (domain: cisco.com, ip: 173.37.86.78, mailfrom: yurade@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=18543; q=dns/txt; s=iport01; t=1793862502; x=1795072102; h=from:to:subject:date:message-id:in-reply-to:references: mime-version:content-transfer-encoding; bh=SK08MsGbiIcAfo8gMpFZwx3QhXX7krb5YekfmbmglGs=; b=cZikn5h3vQMWqZuhJIQlR/0kZ4S3DJMu18dB1RemDHBOdb8LNA9pTUkO AenpTI9R/9TAA6zqjL8zmZaWBZ41SPmP+xY0vroWKXtNjr4yIorObEL+a XLZKvNScN28yiBxdaIHtxS8zF/9Qns7PT7T2Hgz9HRJMuJJujqzXU75pa 72Z/+BvlApZUv4bEpc8LLW9CJlitQojUNSdkRXuJwagG6pGPufs/eB7O/ qMdO3pZLqky7cYQFbQ/qQrbI3m7wGc0qjpHWNqJXIwZMxpbjPjXTC7vWC j1LzmSa/i28aRoIF+J9wPGnuEbQ10HlfbxlpswIcaCJfmvQofUS7yNo7L A==; X-CSE-ConnectionGUID: uKMO/SwoRvGMXVLS/VJU7w== X-CSE-MsgGUID: zWjzCEqaR660yUpVZOz/+w== X-IPAS-Result: A0AnAAC/Kexq/5P/Ja1aHQEBAQEJARIBBQUBgXwIAQsBglZ1YENJjHKHOIIhA4ETnQ4UgWoPAQEBD0QNBAEBhD9GAo4dAiY0CQ4BAgQDAgMBAQEBAQEBAQEBAQEKAQEFAQEBAgEHBYEOE4ZPDZASAQIBAycLARgBPRwDAQIWGSsjCBmDAgGCdAMRvD6BeTOBAYMpAT8CQ1DZS4FoAQsUAYE4AYU/iCNdGAGEfScbgUlEgRWCc3aBBYFcAQGBGS9XhgcEgy6BWoEDjBUCgVKGcUiBHgNZLAFVEw0KCwcFgWgDNRIqFW4yHYEjPheBBxsGBYEdgR6BEoEHXYErKF4hgQiBdgKCUYIAAgFJQQ4HRVMJJUMSRyQiCBIJARMaLAqBGjU8CSY/FQxkCxoFYQgCDQxyORAwHiQvBRwmQBsKGBgNSBEsNxUZBD5uB5Ivgjk4HwEBehMBBwMgAQRMLxJnDUOlWp9TgT6EKowkj0KFeBozhVulFguWGoJjhVSFY4JTlgFQhGmBaDyBKBsECwczGggbFTuCZwlKGQ+OKw4LgWiBeIF/g2XIdyYyAgkyAQEHAgcOAwuBaJF+AQE IronPort-Data: A9a23:Axz4M6OPP/9dhtDvrR3zlsFynXyQoLVcMsEvi/4bfWQNrUor12AAz WQXDzuAOPbfZGGnftFza4i1/E0A7Z6AmoNqSXM5pCpnJ55oRWUpJjg4wmPYZX76whjrFRo/h ykmQoCeap1yFjmD9k/F3oHJ9RFUzbuPSqf3FNnKMyVwQR4MYCo6gHqPocZh6mJTqYb/WVzlV e/a+ZWFZgb8g2IsaAr41orawP9RlKWq0N8nlgRWicBj5Df2i3QTBZQDEqC9R1OQapVUBOOzW 9HYx7i/+G7Dlz91Yj9yuu+mGqGiaue60Tmm0hK6aYD76vRxjnBaPpIACRYpQRw/ZwNlMDxG4 I4lWZSYEW/FN0BX8QgXe0Ew/ypWZcWq9FJbSJSymZT78qHIT5fj6+hNKWZxBpIfwPhYJFFw0 cdfFhIwMQ/W0opawJrjIgVtrt4oIM+uOMYUvWttiGiBS/0nWpvEBa7N4Le03h9p2ZsIRqmYP ZdEL2MzM3wsYDUXUrsTIIMjhu6ki1H0ciZTrxSeoq9fD237kVAqgeC8aYeKEjCMbddqnFSzi 3vPxTqnWgAnLOKt7ijCsW3504cjmgu+Aur+DoaQ8eZnhlCWzGEfBBAaEFe2v/S9okq/QM5Eb UsM9ywjqKI/+ECmQp/6RRLQnZKflgQXV9wVF6gx7xuAj/OMpQ2YHWMDCDVGbbTKqfMLeNDj7 XfR9/uBONClmOf9pa61nltMkQ6PBA== IronPort-HdrOrdr: A9a23:3LH9/6q2HNUiW7LCE3qm70oaV5oHeYIsimQD101hICG9Ffbo8/ xG88506faZslsssTQb6LO90cq7MBbhHOBOgLX5VI3KNGKNhILrFvAB0WKI+VLd8kPFmtK1rZ 0BT4FOTPvtEFN9kcH2pCO8E9om3Z271ZrAv5a485+oJjsaEp2JKGxCe2CmLnE= X-Talos-CUID: 9a23:xn+OpGwNqSsbbeRLN5ltBgUJOeUCNUbCy03ODGqIN0JSD4O3dACPrfY= X-Talos-MUID: 9a23:m3tYdQWOlqwtwFLq/C3ThiNJGd5h2frwUFEwias0nNvDdjMlbg== X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.27,196,1787011200"; d="scan'208";a="543909157" Received: from rcdn-l-core-10.cisco.com ([173.37.255.147]) by rcdn-iport-7.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 05 Nov 2026 07:08:21 +0000 Received: from sjc-ads-7310.cisco.com (sjc-ads-7310.cisco.com [10.30.220.95]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "ciscoit-managed-infra-smtp-auth.cisco.com", Issuer "Internal Private TLS SubCA" (verified OK)) by rcdn-l-core-10.cisco.com (Postfix) with ESMTPS id 63AF4180008A7 for ; Thu, 5 Nov 2026 07:08:21 +0000 (GMT) Received: by sjc-ads-7310.cisco.com (Postfix, from userid 1889728) id BA012CC124C; Wed, 4 Nov 2026 23:08:20 -0800 (PST) From: Yogita Urade To: openembedded-devel@lists.openembedded.org Subject: [oe][meta-oe][wrynose][PATCH 6/6] python3-tornado: Fix CVE-2026-116578 Date: Wed, 4 Nov 2026 23:08:19 -0800 Message-Id: <20261105070819.2086816-7-yurade@cisco.com> X-Mailer: git-send-email 2.35.6 In-Reply-To: <20261105070819.2086816-1-yurade@cisco.com> References: <20261105070819.2086816-1-yurade@cisco.com> MIME-Version: 1.0 X-Outbound-Client-TLS: VERIFIED;sjc-ads-7310.cisco.com [10.30.220.95];TLSv1.3;TLS_AES_256_GCM_SHA384;256;ciscoit-managed-infra-smtp-auth.cisco.com X-Outbound-SMTP-Client: 10.30.220.95, sjc-ads-7310.cisco.com X-Outbound-Node: rcdn-l-core-10.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by lin-tor-korg-lkml-alma10-1 with HTTPS for ; Thu, 05 Nov 2026 07:08:29 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/131866 This patch applies the upstream fix as referenced in [4], using the commit shown in [1], [2] and [3]. [1] https://github.com/tornadoweb/tornado/commit/940ea725bdd9bc68231d68a105eef17ea6e11233 [2] https://github.com/tornadoweb/tornado/commit/ccb6a2e5482f17997efd4a74b8200d5b4babc035 [3] https://github.com/tornadoweb/tornado/commit/cc75ab0eb3af459c714895a49f682ad354f50117 [4] https://nvd.nist.gov/vuln/detail/CVE-2026-116578 Signed-off-by: Yogita Urade --- .../python3-tornado/CVE-2026-116578_p1.patch | 62 ++++++ .../python3-tornado/CVE-2026-116578_p2.patch | 106 ++++++++++ .../python3-tornado/CVE-2026-116578_p3.patch | 192 ++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 3 + 4 files changed, 363 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p1.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p2.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p3.patch diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p1.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p1.patch new file mode 100644 index 0000000000..5a146dfda5 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p1.patch @@ -0,0 +1,62 @@ +From 940ea725bdd9bc68231d68a105eef17ea6e11233 Mon Sep 17 00:00:00 2001 +From: Ben Darnell +Date: Tue, 6 Oct 2026 13:06:04 -0400 +Subject: [PATCH] web: Block open redirects in RedirectHandler + +When the redirect target is formatted from parts of the input, and +one of those parts appears at the start of the new URL, this can +be used to construct protocol-relative URLs which effectively +make an open redirect. Now we refuse to redirect to a +protocol-relative URL unless the double slash was present in the +configured pattern. + +CVE: CVE-2026-116578 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/940ea725bdd9bc68231d68a105eef17ea6e11233] + +Signed-off-by: Yogita Urade +--- + tornado/test/web_test.py | 12 ++++++++++++ + tornado/web.py | 4 ++++ + 2 files changed, 16 insertions(+) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 3ecb3ef5..6008fb50 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -3565,6 +3565,18 @@ class RedirectHandlerTest(WebTestCase): + self.assertEqual(response.code, 301) + self.assertEqual(response.headers["Location"], "/b/a/c") + ++ def test_redirect_double_slash(self): ++ # Note that the redirect pattern we're using reorders the components. ++ response = self.fetch("/a/example.com/b", follow_redirects=False) ++ self.assertEqual(response.code, 301) ++ self.assertEqual(response.headers["Location"], "/example.com/a/b") ++ ++ # With a double slash this becomes //example.com/a/b, which is a protocol-relative ++ # redirect to another host, which we disallow because it is an open redirect. ++ with ExpectLog(app_log, "Uncaught exception"): ++ response = self.fetch("/a//example.com/a/b", follow_redirects=False) ++ self.assertEqual(response.code, 500) ++ + + class AcceptLanguageTest(WebTestCase): + """Test evaluation of Accept-Language header""" +diff --git a/tornado/web.py b/tornado/web.py +index d3257221..be3b07c0 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -2707,6 +2707,10 @@ class RedirectHandler(RequestHandler): + to_url, + list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore + ) ++ if to_url.startswith("//") and not self._url.startswith("//"): ++ raise ValueError( ++ "Redirect URL cannot start with '//' if template does not." ++ ) + self.redirect(to_url, permanent=self._permanent) + + +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p2.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p2.patch new file mode 100644 index 0000000000..9a41ca93eb --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p2.patch @@ -0,0 +1,106 @@ +From ccb6a2e5482f17997efd4a74b8200d5b4babc035 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Sat, 10 Oct 2026 16:48:22 +0000 +Subject: [PATCH] web: Catch backslash and whitespace variants of + protocol-relative redirects + +The open-redirect checks in RedirectHandler and StaticFileHandler only +looked for a literal leading "//". Browsers follow the WHATWG URL spec, +which treats backslashes like slashes and drops tabs and newlines, so +Location headers such as "/\evil.com" or "//evil.com" are also +protocol-relative redirects to another host. + +RedirectHandler was exploitable this way because path arguments are +url-decoded before they are substituted into the template, so a link +to e.g. /%5Cevil.com/... produced a raw backslash in the redirect. +StaticFileHandler checks the undecoded request path, which a browser +cannot make contain a raw backslash, but harden it the same way. + +CVE: CVE-2026-116578 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/ccb6a2e5482f17997efd4a74b8200d5b4babc035] + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01Jhn71YM65mTQ8sdDrEMwS8 +Signed-off-by: Yogita Urade +--- + tornado/test/web_test.py | 16 ++++++++++++++++ + tornado/web.py | 18 ++++++++++++++++-- + 2 files changed, 32 insertions(+), 2 deletions(-) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 6008fb50..b6b62e96 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -1763,6 +1763,13 @@ class StaticDefaultFilenameRootTest(WebTestCase): + ) + self.assertEqual(response.code, 403) + ++ def test_no_open_redirect_backslash(self): ++ # Browsers treat a backslash like a slash, so a redirect to /\evil.com ++ # is protocol-relative too. This form doesn't need the server-side path. ++ with ExpectLog(gen_log, ".*cannot redirect path with two initial slashes"): ++ response = self.fetch("/\\evil.com/../dir", follow_redirects=False) ++ self.assertEqual(response.code, 403) ++ + + class StaticFileWithPathTest(WebTestCase): + def get_app_kwargs(self): +@@ -3577,6 +3584,15 @@ class RedirectHandlerTest(WebTestCase): + response = self.fetch("/a//example.com/a/b", follow_redirects=False) + self.assertEqual(response.code, 500) + ++ # Browsers treat backslashes like slashes and ignore tabs and newlines, ++ # so these variants are also protocol-relative. Path arguments are ++ # url-decoded, so they can be smuggled in with percent-encoding. ++ for path in ["/a/%5Cexample.com/b", "/a/%09/example.com/b"]: ++ with self.subTest(path=path): ++ with ExpectLog(app_log, "Uncaught exception"): ++ response = self.fetch(path, follow_redirects=False) ++ self.assertEqual(response.code, 500) ++ + + class AcceptLanguageTest(WebTestCase): + """Test evaluation of Accept-Language header""" +diff --git a/tornado/web.py b/tornado/web.py +index be3b07c0..62c0f7d5 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -2707,7 +2707,7 @@ class RedirectHandler(RequestHandler): + to_url, + list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore + ) +- if to_url.startswith("//") and not self._url.startswith("//"): ++ if _is_protocol_relative(to_url) and not _is_protocol_relative(self._url): + raise ValueError( + "Redirect URL cannot start with '//' if template does not." + ) +@@ -3034,7 +3034,7 @@ class StaticFileHandler(RequestHandler): + # but there is some prefix to the path that was already + # trimmed by the routing + if not self.request.path.endswith("/"): +- if self.request.path.startswith("//"): ++ if _is_protocol_relative(self.request.path): + # A redirect with two initial slashes is a "protocol-relative" URL. + # This means the next path segment is treated as a hostname instead + # of a part of the path, making this effectively an open redirect. +@@ -3926,3 +3926,17 @@ def _create_signature_v2(secret: Union[str, bytes], s: bytes) -> bytes: + + def is_absolute(path: str) -> bool: + return any(path.startswith(x) for x in ["/", "http:", "https:"]) ++ ++ ++def _is_protocol_relative(url: str) -> bool: ++ """Returns True if a browser would treat ``url`` as a protocol-relative URL. ++ ++ A protocol-relative URL (``//example.com/path``) names a host, so redirecting ++ to one that was built from untrusted input is an open redirect. Browsers ++ follow the WHATWG URL spec, which strips leading control characters and ++ spaces, removes tabs and newlines anywhere in the URL, and treats backslashes ++ like forward slashes, so a plain ``startswith("//")`` check is not enough. ++ """ ++ url = url.lstrip("".join(chr(i) for i in range(0x21))) ++ url = url.replace("\t", "").replace("\n", "").replace("\r", "") ++ return url[:1] in ("/", "\\") and url[1:2] in ("/", "\\") +-- +2.35.6 + diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p3.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p3.patch new file mode 100644 index 0000000000..e57924d6bf --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2026-116578_p3.patch @@ -0,0 +1,192 @@ +From cc75ab0eb3af459c714895a49f682ad354f50117 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Sat, 10 Oct 2026 17:56:53 +0000 +Subject: [PATCH] web: Don't let RedirectHandler substitutions change the + scheme or netloc + +Rather than looking only for protocol-relative URLs, RedirectHandler now +requires the scheme and netloc (user info, host, and port) of the +redirect to be the same as those of the template formatted with +placeholder values. This also covers templates like +"https://example.com{0}", where a substitution could extend the host +(".evil.com") or add user info ("@evil.com"), and templates beginning +with a substitution, which could otherwise be given an absolute URL. +Such requests, and protocol-relative ones, now get a 400 response +instead of a 500 from an uncaught ValueError. + +A consequence is that substitutions can no longer be used in the scheme +or netloc part of a template. + +CVE: CVE-2026-116578 +Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/cc75ab0eb3af459c714895a49f682ad354f50117] + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01Jhn71YM65mTQ8sdDrEMwS8 +Signed-off-by: Yogita Urade +--- + tornado/test/web_test.py | 42 ++++++++++++++++++++++++++--- + tornado/web.py | 58 ++++++++++++++++++++++++++++++++-------- + 2 files changed, 85 insertions(+), 15 deletions(-) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index b6b62e96..1438b6f3 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -3549,6 +3549,8 @@ class RedirectHandlerTest(WebTestCase): + return [ + ("/src", WebRedirectHandler, {"url": "/dst"}), + ("/src2", WebRedirectHandler, {"url": "/dst2?foo=bar"}), ++ (r"/any/(.*)", WebRedirectHandler, {"url": "{0}"}), ++ (r"/moved(.*)", WebRedirectHandler, {"url": "https://example.com{0}"}), + (r"/(.*?)/(.*?)/(.*)", WebRedirectHandler, {"url": "/{1}/{0}/{2}"}), + ] + +@@ -3580,18 +3582,50 @@ class RedirectHandlerTest(WebTestCase): + + # With a double slash this becomes //example.com/a/b, which is a protocol-relative + # redirect to another host, which we disallow because it is an open redirect. +- with ExpectLog(app_log, "Uncaught exception"): ++ with ExpectLog(gen_log, ".*Redirect URL cannot change the scheme or netloc"): + response = self.fetch("/a//example.com/a/b", follow_redirects=False) +- self.assertEqual(response.code, 500) ++ self.assertEqual(response.code, 400) + + # Browsers treat backslashes like slashes and ignore tabs and newlines, + # so these variants are also protocol-relative. Path arguments are + # url-decoded, so they can be smuggled in with percent-encoding. + for path in ["/a/%5Cexample.com/b", "/a/%09/example.com/b"]: + with self.subTest(path=path): +- with ExpectLog(app_log, "Uncaught exception"): ++ with ExpectLog( ++ gen_log, ".*Redirect URL cannot change the scheme or netloc" ++ ): ++ response = self.fetch(path, follow_redirects=False) ++ self.assertEqual(response.code, 400) ++ ++ def test_redirect_cannot_change_host(self): ++ # Substitutions directly after the host can extend it or add userinfo. ++ response = self.fetch("/moved/x", follow_redirects=False) ++ self.assertEqual(response.code, 301) ++ self.assertEqual(response.headers["Location"], "https://example.com/x") ++ for path in ["/moved.evil.com/x", "/moved@evil.com/x", "/moved:8080/x"]: ++ with self.subTest(path=path): ++ with ExpectLog( ++ gen_log, ".*Redirect URL cannot change the scheme or netloc" ++ ): ++ response = self.fetch(path, follow_redirects=False) ++ self.assertEqual(response.code, 400) ++ ++ def test_template_starting_with_substitution(self): ++ # A relative URL is fine, but the client cannot supply an absolute one. ++ response = self.fetch("/any/foo/bar", follow_redirects=False) ++ self.assertEqual(response.code, 301) ++ self.assertEqual(response.headers["Location"], "foo/bar") ++ for path in [ ++ "/any/https://evil.com/", ++ "/any/%5C%5Cevil.com/", ++ "/any/javascript:alert(1)", ++ ]: ++ with self.subTest(path=path): ++ with ExpectLog( ++ gen_log, ".*Redirect URL cannot change the scheme or netloc" ++ ): + response = self.fetch(path, follow_redirects=False) +- self.assertEqual(response.code, 500) ++ self.assertEqual(response.code, 400) + + + class AcceptLanguageTest(WebTestCase): +diff --git a/tornado/web.py b/tornado/web.py +index 62c0f7d5..0b67216a 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -2693,6 +2693,14 @@ class RedirectHandler(RequestHandler): + .. versionchanged:: 5.0 + If any query arguments are present, they will be copied to the + destination URL. ++ ++ .. versionchanged:: 6.6 ++ Substituted values may no longer change the scheme or netloc (user ++ info, host, and port) of the destination URL. For example, with a ++ ``url`` of ``"/{0}/b"``, a request that would redirect to ++ ``//example.com/b`` (a protocol-relative URL pointing to another host) ++ now fails with a 400 error. As a consequence, substitutions cannot be ++ used in the scheme or netloc part of the ``url``. + """ + + def initialize(self, url: str, permanent: bool = True) -> None: +@@ -2701,16 +2709,24 @@ class RedirectHandler(RequestHandler): + + def get(self, *args: Any, **kwargs: Any) -> None: + to_url = self._url.format(*args, **kwargs) ++ # The scheme and netloc of the redirect must come from the template, not ++ # from the request. Find out what they are by formatting the template ++ # with a placeholder that cannot extend a scheme or netloc. ++ expected_url = self._url.format( ++ *("/" for _ in args), **{k: "/" for k in kwargs} ++ ) ++ try: ++ unchanged = _scheme_and_netloc(to_url) == _scheme_and_netloc(expected_url) ++ except ValueError: ++ unchanged = False ++ if not unchanged: ++ raise HTTPError(400, "Redirect URL cannot change the scheme or netloc") + if self.request.query_arguments: + # TODO: figure out typing for the next line. + to_url = httputil.url_concat( + to_url, + list(httputil.qs_to_qsl(self.request.query_arguments)), # type: ignore + ) +- if _is_protocol_relative(to_url) and not _is_protocol_relative(self._url): +- raise ValueError( +- "Redirect URL cannot start with '//' if template does not." +- ) + self.redirect(to_url, permanent=self._permanent) + + +@@ -3928,15 +3944,35 @@ def is_absolute(path: str) -> bool: + return any(path.startswith(x) for x in ["/", "http:", "https:"]) + + ++def _normalize_url_like_browser(url: str) -> str: ++ """Approximates how a browser cleans up a URL before parsing it. ++ ++ Browsers follow the WHATWG URL spec, which strips leading and trailing ++ control characters and spaces, removes tabs and newlines anywhere in the ++ URL, and (for http and https) treats backslashes like forward slashes. Checks ++ on redirect URLs built from untrusted input must account for this. ++ """ ++ url = url.strip("".join(chr(i) for i in range(0x21))) ++ url = url.replace("\t", "").replace("\n", "").replace("\r", "") ++ return url.replace("\\", "/") ++ ++ + def _is_protocol_relative(url: str) -> bool: + """Returns True if a browser would treat ``url`` as a protocol-relative URL. + + A protocol-relative URL (``//example.com/path``) names a host, so redirecting +- to one that was built from untrusted input is an open redirect. Browsers +- follow the WHATWG URL spec, which strips leading control characters and +- spaces, removes tabs and newlines anywhere in the URL, and treats backslashes +- like forward slashes, so a plain ``startswith("//")`` check is not enough. ++ to one that was built from untrusted input is an open redirect. + """ +- url = url.lstrip("".join(chr(i) for i in range(0x21))) +- url = url.replace("\t", "").replace("\n", "").replace("\r", "") +- return url[:1] in ("/", "\\") and url[1:2] in ("/", "\\") ++ return _normalize_url_like_browser(url).startswith("//") ++ ++ ++def _scheme_and_netloc(url: str) -> tuple[str, str]: ++ """Returns the scheme and netloc a browser would see in ``url``. ++ ++ The netloc includes the user info and port as well as the host. ++ ++ Both are empty for a relative URL. Raises `ValueError` for some malformed ++ URLs. ++ """ ++ parts = urllib.parse.urlsplit(_normalize_url_like_browser(url)) ++ return parts.scheme.lower(), parts.netloc.lower() +-- +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 87b6721320..dd61da78be 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 @@ -19,6 +19,9 @@ SRC_URI += "file://CVE-2026-91990.patch \ file://CVE-2026-116577_p1.patch \ file://CVE-2026-116577_p2.patch \ file://CVE-2026-116577_p3.patch \ + file://CVE-2026-116578_p1.patch \ + file://CVE-2026-116578_p2.patch \ + file://CVE-2026-116578_p3.patch \ " inherit pypi python_setuptools_build_meta