From a76889bae86b64c208d7f8228a19fd917fb91b39 Mon Sep 17 00:00:00 2001 From: pkgagent Date: Wed, 29 Apr 2026 11:59:42 +0800 Subject: [PATCH 1/3] fix CVE-2026-31958 --- python-tornado.spec | 1 + tornado-6.3.3-CVE-2026-31958.patch | 275 +++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 tornado-6.3.3-CVE-2026-31958.patch diff --git a/python-tornado.spec b/python-tornado.spec index d70b82e..676d0b0 100644 --- a/python-tornado.spec +++ b/python-tornado.spec @@ -13,6 +13,7 @@ Patch0001: fix-CVE-2024-52804.patch Patch0002: 0001-fix-CVE-2025-47287.patch Patch0003: 0002-fix-CVE-2025-67724.patch Patch0004: 0003-fix-CVE-2025-67725_67726.patch +Patch0005: tornado-6.3.3-CVE-2026-31958.patch Patch3000: Do-not-turn-DeprecationWarning-into-Exception.patch diff --git a/tornado-6.3.3-CVE-2026-31958.patch b/tornado-6.3.3-CVE-2026-31958.patch new file mode 100644 index 0000000..12a7d08 --- /dev/null +++ b/tornado-6.3.3-CVE-2026-31958.patch @@ -0,0 +1,275 @@ +diff --git a/tornado/httputil.py b/tornado/httputil.py +index b0a46a5..e29586a 100644 +--- a/tornado/httputil.py ++++ b/tornado/httputil.py +@@ -22,6 +22,7 @@ via `tornado.web.RequestHandler.request`. + import calendar + import collections.abc + import copy ++import dataclasses + import datetime + import email.utils + from functools import lru_cache +@@ -131,7 +132,7 @@ class HTTPHeaders(collections.abc.MutableMapping): + + # new public methods + +- def add(self, name: str, value: str) -> None: ++ def add(self, name: str, value: str, *, _chars_are_bytes: bool = True) -> None: + """Adds a new value for the given key.""" + norm_name = _normalize_header(name) + self._last_key = norm_name +@@ -158,7 +159,7 @@ class HTTPHeaders(collections.abc.MutableMapping): + for value in values: + yield (name, value) + +- def parse_line(self, line: str) -> None: ++ def parse_line(self, line: str, *, _chars_are_bytes: bool = True) -> None: + """Updates the dictionary with a single header line. + + >>> h = HTTPHeaders() +@@ -178,10 +179,10 @@ class HTTPHeaders(collections.abc.MutableMapping): + name, value = line.split(":", 1) + except ValueError: + raise HTTPInputError("no colon in header line") +- self.add(name, value.strip()) ++ self.add(name, value.strip(), _chars_are_bytes=_chars_are_bytes) + + @classmethod +- def parse(cls, headers: str) -> "HTTPHeaders": ++ def parse(cls, headers: str, *, _chars_are_bytes: bool = True) -> "HTTPHeaders": + """Returns a dictionary from HTTP header text. + + >>> h = HTTPHeaders.parse("Content-Type: text/html\\r\\nContent-Length: 42\\r\\n") +@@ -201,7 +202,7 @@ class HTTPHeaders(collections.abc.MutableMapping): + if line.endswith("\r"): + line = line[:-1] + if line: +- h.parse_line(line) ++ h.parse_line(line, _chars_are_bytes=_chars_are_bytes) + return h + + # MutableMapping abstract method implementations. +@@ -741,12 +742,90 @@ def _int_or_none(val: str) -> Optional[int]: + return int(val) + + ++@dataclasses.dataclass ++class ParseMultipartConfig: ++ """This class configures the parsing of ``multipart/form-data`` request bodies. ++ ++ Its primary purpose is to place limits on the size and complexity of request messages ++ to avoid potential denial-of-service attacks. ++ ++ .. versionadded:: 6.5.5 ++ """ ++ ++ enabled: bool = True ++ """Set this to false to disable the parsing of ``multipart/form-data`` requests entirely. ++ ++ This may be desirable for applications that do not need to handle this format, since ++ multipart request have a history of DoS vulnerabilities in Tornado. Multipart requests ++ are used primarily for ```` in HTML forms, or in APIs that mimic this ++ format. File uploads that use the HTTP ``PUT`` method generally do not use the multipart ++ format. ++ """ ++ ++ max_parts: int = 100 ++ """The maximum number of parts accepted in a multipart request. ++ ++ Each ```` element in an HTML form corresponds to at least one "part". ++ """ ++ ++ max_part_header_size: int = 10 * 1024 ++ """The maximum size of the headers for each part of a multipart request. ++ ++ The header for a part contains the name of the form field and optionally the filename ++ and content type of the uploaded file. ++ """ ++ ++ ++@dataclasses.dataclass ++class ParseBodyConfig: ++ """This class configures the parsing of request bodies. ++ ++ .. versionadded:: 6.5.5 ++ """ ++ ++ multipart: ParseMultipartConfig = dataclasses.field( ++ default_factory=ParseMultipartConfig ++ ) ++ """Configuration for ``multipart/form-data`` request bodies.""" ++ ++ ++_DEFAULT_PARSE_BODY_CONFIG = ParseBodyConfig() ++ ++ ++def set_parse_body_config(config: ParseBodyConfig) -> None: ++ r"""Sets the **global** default configuration for parsing request bodies. ++ ++ This global setting is provided as a stopgap for applications that need to raise the limits ++ introduced in Tornado 6.5.5, or who wish to disable the parsing of multipart/form-data bodies ++ entirely. Non-global configuration for this functionality will be introduced in a future ++ release. ++ ++ >>> content_type = "multipart/form-data; boundary=foo" ++ >>> multipart_body = b"--foo--\r\n" ++ >>> parse_body_arguments(content_type, multipart_body, {}, {}) ++ >>> multipart_config = ParseMultipartConfig(enabled=False) ++ >>> config = ParseBodyConfig(multipart=multipart_config) ++ >>> set_parse_body_config(config) ++ >>> parse_body_arguments(content_type, multipart_body, {}, {}) ++ Traceback (most recent call last): ++ ... ++ tornado.httputil.HTTPInputError: ...: multipart/form-data parsing is disabled ++ >>> set_parse_body_config(ParseBodyConfig()) # reset to defaults ++ ++ .. versionadded:: 6.5.5 ++ """ ++ global _DEFAULT_PARSE_BODY_CONFIG ++ _DEFAULT_PARSE_BODY_CONFIG = config ++ ++ + def parse_body_arguments( + content_type: str, + body: bytes, + arguments: Dict[str, List[bytes]], + files: Dict[str, List[HTTPFile]], + headers: Optional[HTTPHeaders] = None, ++ *, ++ config: Optional[ParseBodyConfig] = None, + ) -> None: + """Parses a form request body. + +@@ -756,6 +835,8 @@ def parse_body_arguments( + and ``files`` parameters are dictionaries that will be updated + with the parsed contents. + """ ++ if config is None: ++ config = _DEFAULT_PARSE_BODY_CONFIG + if content_type.startswith("application/x-www-form-urlencoded"): + if headers and "Content-Encoding" in headers: + raise HTTPInputError( +@@ -776,10 +857,15 @@ def parse_body_arguments( + ) + try: + fields = content_type.split(";") ++ if fields[0].strip() != "multipart/form-data": ++ # This catches "Content-Type: multipart/form-dataxyz" ++ raise HTTPInputError("Invalid content type") + for field in fields: + k, sep, v = field.strip().partition("=") + if k == "boundary" and v: +- parse_multipart_form_data(utf8(v), body, arguments, files) ++ parse_multipart_form_data( ++ utf8(v), body, arguments, files, config=config.multipart ++ ) + break + else: + raise HTTPInputError("multipart boundary not found") +@@ -792,6 +878,8 @@ def parse_multipart_form_data( + data: bytes, + arguments: Dict[str, List[bytes]], + files: Dict[str, List[HTTPFile]], ++ *, ++ config: Optional[ParseMultipartConfig] = None, + ) -> None: + """Parses a ``multipart/form-data`` body. + +@@ -804,6 +892,10 @@ def parse_multipart_form_data( + Now recognizes non-ASCII filenames in RFC 2231/5987 + (``filename*=``) format. + """ ++ if config is None: ++ config = _DEFAULT_PARSE_BODY_CONFIG.multipart ++ if not config.enabled: ++ raise HTTPInputError("multipart/form-data parsing is disabled") + # The standard allows for the boundary to be quoted in the header, + # although it's rare (it happens at least for google app engine + # xmpp). I think we're also supposed to handle backslash-escapes +@@ -815,13 +907,17 @@ def parse_multipart_form_data( + if final_boundary_index == -1: + raise HTTPInputError("Invalid multipart/form-data: no final boundary found") + parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n") ++ if len(parts) > config.max_parts: ++ raise HTTPInputError("multipart/form-data has too many parts") + for part in parts: + if not part: + continue + eoh = part.find(b"\r\n\r\n") + if eoh == -1: + raise HTTPInputError("multipart/form-data missing headers") +- headers = HTTPHeaders.parse(part[:eoh].decode("utf-8")) ++ if eoh > config.max_part_header_size: ++ raise HTTPInputError("multipart/form-data part header too large") ++ headers = HTTPHeaders.parse(part[:eoh].decode("utf-8"), _chars_are_bytes=False) + disp_header = headers.get("Content-Disposition", "") + disposition, disp_params = _parse_header(disp_header) + if disposition != "form-data" or not part.endswith(b"\r\n"): +@@ -1027,7 +1123,7 @@ def doctests(): + # type: () -> unittest.TestSuite + import doctest + +- return doctest.DocTestSuite() ++ return doctest.DocTestSuite(optionflags=doctest.ELLIPSIS) + + + _netloc_re = re.compile(r"^(.+):(\d+)$") +diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py +index 9816e01..c6e1818 100644 +--- a/tornado/test/httputil_test.py ++++ b/tornado/test/httputil_test.py +@@ -9,6 +9,7 @@ from tornado.httputil import ( + qs_to_qsl, + HTTPInputError, + HTTPFile, ++ ParseMultipartConfig, + ) + from tornado.escape import utf8, native_str + from tornado.log import gen_log +@@ -280,10 +281,45 @@ Foo + return time.time() - start + + d1 = f(1_000) ++ # Note that headers larger than this are blocked by the default configuration. + d2 = f(10_000) + if d2 / d1 > 20: + self.fail(f"Disposition param parsing is not linear: {d1=} vs {d2=}") + ++ def test_multipart_config(self): ++ boundary = b"1234" ++ body = b"""--1234 ++Content-Disposition: form-data; name="files"; filename="ab.txt" ++ ++--1234--""".replace( ++ b"\n", b"\r\n" ++ ) ++ config = ParseMultipartConfig() ++ args, files = form_data_args() ++ parse_multipart_form_data(boundary, body, args, files, config=config) ++ self.assertEqual(files["files"][0]["filename"], "ab.txt") ++ ++ config_no_parts = ParseMultipartConfig(max_parts=0) ++ with self.assertRaises(HTTPInputError) as cm: ++ parse_multipart_form_data( ++ boundary, body, args, files, config=config_no_parts ++ ) ++ self.assertIn("too many parts", str(cm.exception)) ++ ++ config_small_headers = ParseMultipartConfig(max_part_header_size=10) ++ with self.assertRaises(HTTPInputError) as cm: ++ parse_multipart_form_data( ++ boundary, body, args, files, config=config_small_headers ++ ) ++ self.assertIn("header too large", str(cm.exception)) ++ ++ config_disabled = ParseMultipartConfig(enabled=False) ++ with self.assertRaises(HTTPInputError) as cm: ++ parse_multipart_form_data( ++ boundary, body, args, files, config=config_disabled ++ ) ++ self.assertIn("multipart/form-data parsing is disabled", str(cm.exception)) ++ + + class HTTPHeadersTest(unittest.TestCase): + def test_multi_line(self): -- Gitee From 0c875350e3faca7d78dbf8c6cb780a0bdb1e6184 Mon Sep 17 00:00:00 2001 From: pkgagent Date: Wed, 29 Apr 2026 12:06:51 +0800 Subject: [PATCH 2/3] fix CVE-2026-35536 --- python-tornado.spec | 1 + tornado-6.3.3-CVE-2026-35536.patch | 154 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tornado-6.3.3-CVE-2026-35536.patch diff --git a/python-tornado.spec b/python-tornado.spec index 676d0b0..afe1ff7 100644 --- a/python-tornado.spec +++ b/python-tornado.spec @@ -14,6 +14,7 @@ Patch0002: 0001-fix-CVE-2025-47287.patch Patch0003: 0002-fix-CVE-2025-67724.patch Patch0004: 0003-fix-CVE-2025-67725_67726.patch Patch0005: tornado-6.3.3-CVE-2026-31958.patch +Patch0006: tornado-6.3.3-CVE-2026-35536.patch Patch3000: Do-not-turn-DeprecationWarning-into-Exception.patch diff --git a/tornado-6.3.3-CVE-2026-35536.patch b/tornado-6.3.3-CVE-2026-35536.patch new file mode 100644 index 0000000..f059ed4 --- /dev/null +++ b/tornado-6.3.3-CVE-2026-35536.patch @@ -0,0 +1,154 @@ +From 24a2d96ea115f663b223887deb0060f13974c104 Mon Sep 17 00:00:00 2001 +From: Ben Darnell +Date: Fri, 6 Mar 2026 14:50:25 -0500 +Subject: [PATCH] web: Validate characters in all cookie attributes. + +Our previous control character check was missing a check for +U+007F, and also semicolons, which are only allowed in quoted +parts of values. This commit checks all attributes and +updates the set of disallowed characters. + +Adapted-by: PkgAgent (modified to adapt to opencloudos-stream) + +--- + tornado/test/web_test.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++ + tornado/web.py | 27 +++++++++++++++++-- + 2 files changed, 89 insertions(+), 3 deletions(-) + +diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py +index 2d80591..62e6d86 100644 +--- a/tornado/test/web_test.py ++++ b/tornado/test/web_test.py +@@ -1,3 +1,5 @@ ++import http ++ + from tornado.concurrent import Future + from tornado import gen + from tornado.escape import ( +@@ -291,11 +293,67 @@ class CookieTest(WebTestCase): + self.set_cookie("unicode_args", "blah", domain="foo.com", path="/foo") + + class SetCookieSpecialCharHandler(RequestHandler): ++ # "Special" characters are allowed in cookie values, but trigger special quoting. + def get(self): + self.set_cookie("equals", "a=b") + self.set_cookie("semicolon", "a;b") + self.set_cookie("quote", 'a"b') + ++ class SetCookieForbiddenCharHandler(RequestHandler): ++ def get(self): ++ # Control characters and semicolons raise errors in cookie names and attributes ++ # (but not values, which are tested in SetCookieSpecialCharHandler) ++ for char in list(map(chr, range(0x20))) + [chr(0x7F), ";"]: ++ try: ++ self.set_cookie("foo" + char, "bar") ++ self.write( ++ "Didn't get expected exception for char %r in name\n" % char ++ ) ++ except http.cookies.CookieError as e: ++ if "Invalid cookie attribute name" not in str(e): ++ self.write( ++ "unexpected exception for char %r in name: %s\n" ++ % (char, e) ++ ) ++ ++ try: ++ self.set_cookie("foo", "bar", domain="example" + char + ".com") ++ self.write( ++ "Didn't get expected exception for char %r in domain\n" ++ % char ++ ) ++ except http.cookies.CookieError as e: ++ if "Invalid cookie attribute domain" not in str(e): ++ self.write( ++ "unexpected exception for char %r in domain: %s\n" ++ % (char, e) ++ ) ++ ++ try: ++ self.set_cookie("foo", "bar", path="/" + char) ++ self.write( ++ "Didn't get expected exception for char %r in path\n" % char ++ ) ++ except http.cookies.CookieError as e: ++ if "Invalid cookie attribute path" not in str(e): ++ self.write( ++ "unexpected exception for char %r in path: %s\n" ++ % (char, e) ++ ) ++ ++ try: ++ self.set_cookie("foo", "bar", samesite="a" + char) ++ self.write( ++ "Didn't get expected exception for char %r in samesite\n" ++ % char ++ ) ++ except http.cookies.CookieError as e: ++ if "Invalid cookie attribute samesite" not in str(e): ++ self.write( ++ "unexpected exception for char %r in samesite: %s\n" ++ % (char, e) ++ ) ++ + class SetCookieOverwriteHandler(RequestHandler): + def get(self): + self.set_cookie("a", "b", domain="example.com") +@@ -329,6 +387,7 @@ class CookieTest(WebTestCase): + ("/get", GetCookieHandler), + ("/set_domain", SetCookieDomainHandler), + ("/special_char", SetCookieSpecialCharHandler), ++ ("/forbidden_char", SetCookieForbiddenCharHandler), + ("/set_overwrite", SetCookieOverwriteHandler), + ("/set_max_age", SetCookieMaxAgeHandler), + ("/set_expires_days", SetCookieExpiresDaysHandler), +@@ -385,6 +444,12 @@ class CookieTest(WebTestCase): + response = self.fetch("/get", headers={"Cookie": header}) + self.assertEqual(response.body, utf8(expected)) + ++ def test_set_cookie_forbidden_char(self): ++ response = self.fetch("/forbidden_char") ++ self.assertEqual(response.code, 200) ++ self.maxDiff = 10000 ++ self.assertMultiLineEqual(to_unicode(response.body), "") ++ + def test_set_cookie_overwrite(self): + response = self.fetch("/set_overwrite") + headers = response.headers.get_list("Set-Cookie") +diff --git a/tornado/web.py b/tornado/web.py +index e9fcc89..fe4d8b4 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -645,9 +645,30 @@ class RequestHandler(object): + # The cookie library only accepts type str, in both python 2 and 3 + name = escape.native_str(name) + value = escape.native_str(value) +- if re.search(r"[\x00-\x20]", name + value): +- # Don't let us accidentally inject bad stuff +- raise ValueError("Invalid cookie %r: %r" % (name, value)) ++ if re.search(r"[\x00-\x20]", value): ++ # Legacy check for control characters in cookie values. This check is no longer needed ++ # since the cookie library escapes these characters correctly now. It will be removed ++ # in the next feature release. ++ raise ValueError(f"Invalid cookie {name!r}: {value!r}") ++ for attr_name, attr_value in [ ++ ("name", name), ++ ("domain", domain), ++ ("path", path), ++ ("samesite", samesite), ++ ]: ++ # Cookie attributes may not contain control characters or semicolons (except when ++ # escaped in the value). A check for control characters was added to the http.cookies ++ # library in a Feb 2026 security release; as of March it still does not check for ++ # semicolons. ++ # ++ # When a semicolon check is added to the standard library (and the release has had time ++ # for adoption), this check may be removed, but be mindful of the fact that this may ++ # change the timing of the exception (to the generation of the Set-Cookie header in ++ # flush()). ++ if attr_value is not None and re.search(r"[\x00-\x20\x3b\x7f]", attr_value): ++ raise http.cookies.CookieError( ++ f"Invalid cookie attribute {attr_name}={attr_value!r} for cookie {name!r}" ++ ) + if not hasattr(self, "_new_cookie"): + self._new_cookie = ( + http.cookies.SimpleCookie() -- Gitee From 78c562ba876ca6bedbd90daa0ea2b00d6db4e808 Mon Sep 17 00:00:00 2001 From: pkgagent Date: Wed, 29 Apr 2026 12:06:55 +0800 Subject: [PATCH 3/3] fix CVE-2026-31958, CVE-2026-35536 --- python-tornado.spec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python-tornado.spec b/python-tornado.spec index afe1ff7..a639c24 100644 --- a/python-tornado.spec +++ b/python-tornado.spec @@ -3,7 +3,7 @@ Summary: Scalable, non-blocking web server and tools Name: python-%{srcname} Version: 6.3.3 -Release: 8%{?dist} +Release: 9%{?dist} License: ASL 2.0 URL: https://www.tornadoweb.org Source0: https://github.com/tornadoweb/tornado/archive/v%{version}/%{srcname}-%{version}.tar.gz @@ -81,6 +81,10 @@ export TRAVIS=true %doc demos %changelog +* Wed Apr 29 2026 PkgAgent Robot - 6.3.3-9 +- [Type] security +- [DESC] Fix CVE-2026-31958, CVE-2026-35536 + * Thu Dec 25 2025 bbrucezhang - 6.3.3-8 - [Type] security - [DESC] Fix CVE-2025-67724, CVE-2025-67725, CVE-2025-67726 -- Gitee