File backport-of-the-cve-2026-31958-fix-bsc-1259554.patch of Package salt

From 49f0b8d91c472ccae78abe57683253f00530e9d2 Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <vzhestkov@suse.com>
Date: Tue, 17 Mar 2026 11:56:38 +0100
Subject: [PATCH] Backport of the CVE-2026-31958 fix (bsc#1259554)

Co-authored-by: Ben Darnell <ben@bendarnell.com>
---
 salt/ext/tornado/httputil.py           | 129 +++++++++++++++++++++++--
 salt/ext/tornado/test/httputil_test.py |  62 +++++++++++-
 salt/ext/tornado/test/web_test.py      |  64 ++++++++++++
 salt/ext/tornado/web.py                |  29 +++++-
 4 files changed, 273 insertions(+), 11 deletions(-)

diff --git a/salt/ext/tornado/httputil.py b/salt/ext/tornado/httputil.py
index 78953c5f6bb..b230a85d7b9 100644
--- a/salt/ext/tornado/httputil.py
+++ b/salt/ext/tornado/httputil.py
@@ -71,6 +71,10 @@ except ImportError:
 # To be used with str.strip() and related methods.
 HTTP_WHITESPACE = " \t"
 
+# Roughly the inverse of RequestHandler._VALID_HEADER_CHARS, but permits
+# chars greater than \xFF (which may appear after decoding utf8).
+_FORBIDDEN_HEADER_CHARS_RE = re.compile(r"[\x00-\x08\x0A-\x1F\x7F]")
+
 # RFC 7230 section 3.5: a recipient MAY recognize a single LF as a line
 # terminator and ignore any preceding CR.
 _CRLF_RE = re.compile(r"\r?\n")
@@ -155,6 +159,8 @@ class HTTPHeaders(MutableMapping):
     def add(self, name, value):
         # type: (str, str) -> None
         """Adds a new value for the given key."""
+        if _FORBIDDEN_HEADER_CHARS_RE.search(value):
+            raise HTTPInputError("Invalid header value %r" % value)
         norm_name = _normalized_headers[name]
         self._last_key = norm_name
         if norm_name in self:
@@ -187,13 +193,30 @@ class HTTPHeaders(MutableMapping):
         >>> h.get('content-type')
         'text/html'
         """
-        if line[0].isspace():
+        m = re.search(r"\r?\n$", line)
+        if m:
+            # RFC 9112 section 2.2: a recipient MAY recognize a single LF as a line
+            # terminator and ignore any preceding CR.
+            # TODO(7.0): Remove this support for LF-only line endings.
+            line = line[: m.start()]
+        if not line:
+            # Empty line, or the final CRLF of a header block.
+            return
+        if line[0] in HTTP_WHITESPACE:
             # continuation of a multi-line header
-            new_part = " " + line.lstrip(HTTP_WHITESPACE)
+            # 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 _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:
-            name, value = line.split(":", 1)
+            try:
+                name, value = line.split(":", 1)
+            except ValueError:
+                raise HTTPInputError("no colon in header line")
             self.add(name, value.strip(HTTP_WHITESPACE))
 
     @classmethod
@@ -753,7 +776,84 @@ def _int_or_none(val):
     return int(val)
 
 
-def parse_body_arguments(content_type, body, arguments, files, headers=None):
+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.
+    """
+    def __init__(self, enabled=True, max_parts=100, max_part_header_size=10*1024):
+        self.enabled = enabled
+        """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 ``<input type="file">`` 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.
+        """
+
+        self.max_parts = max_parts
+        """The maximum number of parts accepted in a multipart request.
+
+        Each ``<input>`` element in an HTML form corresponds to at least one "part".
+        """
+
+        self.max_part_header_size = max_part_header_size
+        """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.
+        """
+
+    def __repr__(self):
+        return (f"ParseMultipartConfig(enabled={self.enabled}, "
+                f"max_parts={self.max_parts}, "
+                f"max_part_header_size={self.max_part_header_size})")
+
+
+class ParseBodyConfig:
+    """This class configures the parsing of request bodies.
+    """
+
+    def __init__(self, multipart=None):
+        if multipart is None:
+            multipart = ParseMultipartConfig()
+        self.multipart = multipart
+        """Configuration for ``multipart/form-data`` request bodies."""
+
+    def __repr__(self):
+        return (f"ParseBodyConfig(multipart={self.multipart})")
+
+
+_DEFAULT_PARSE_BODY_CONFIG = ParseBodyConfig()
+
+
+def set_parse_body_config(config):
+    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
+    """
+    global _DEFAULT_PARSE_BODY_CONFIG
+    _DEFAULT_PARSE_BODY_CONFIG = config
+
+
+def parse_body_arguments(content_type, body, arguments, files, headers=None, config=None):
     """Parses a form request body.
 
     Supports ``application/x-www-form-urlencoded`` and
@@ -762,6 +862,8 @@ def parse_body_arguments(content_type, body, arguments, files, headers=None):
     and ``files`` parameters are dictionaries that will be updated
     with the parsed contents.
     """
+    if config is None:
+        config = _DEFAULT_PARSE_BODY_CONFIG
     if headers and "Content-Encoding" in headers:
         raise HTTPInputError(
             "Unsupported Content-Encoding: %s" % headers["Content-Encoding"]
@@ -777,10 +879,15 @@ def parse_body_arguments(content_type, body, arguments, files, headers=None):
     elif content_type.startswith("multipart/form-data"):
         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.multipart
+                    )
                     break
             else:
                 raise HTTPInputError("multipart boundary not found")
@@ -788,13 +895,17 @@ def parse_body_arguments(content_type, body, arguments, files, headers=None):
             raise HTTPInputError("Invalid multipart/form-data: %s" % e)
 
 
-def parse_multipart_form_data(boundary, data, arguments, files):
+def parse_multipart_form_data(boundary, data, arguments, files, config=None):
     """Parses a ``multipart/form-data`` body.
 
     The ``boundary`` and ``data`` parameters are both byte strings.
     The dictionaries given in the arguments and files parameters
     will be updated with the contents of the body.
     """
+    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
@@ -806,12 +917,16 @@ def parse_multipart_form_data(boundary, data, arguments, files):
     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")
+        if eoh > config.max_part_header_size:
+            raise HTTPInputError("multipart/form-data part header too large")
         headers = HTTPHeaders.parse(part[:eoh].decode("utf-8"))
         disp_header = headers.get("Content-Disposition", "")
         disposition, disp_params = _parse_header(disp_header)
@@ -977,7 +1092,7 @@ def _encode_header(key, pdict):
 def doctests():
     import doctest
 
-    return doctest.DocTestSuite()
+    return doctest.DocTestSuite(optionflags=doctest.ELLIPSIS)
 
 
 def split_host_and_port(netloc):
diff --git a/salt/ext/tornado/test/httputil_test.py b/salt/ext/tornado/test/httputil_test.py
index cacd31aa054..d24b739255c 100644
--- a/salt/ext/tornado/test/httputil_test.py
+++ b/salt/ext/tornado/test/httputil_test.py
@@ -4,7 +4,7 @@
 
 
 from __future__ import absolute_import, division, print_function
-from salt.ext.tornado.httputil import url_concat, parse_multipart_form_data, HTTPHeaders, format_timestamp, HTTPServerRequest, parse_request_start_line, parse_cookie
+from salt.ext.tornado.httputil import url_concat, parse_multipart_form_data, HTTPHeaders, format_timestamp, HTTPServerRequest, parse_request_start_line, parse_cookie, ParseMultipartConfig
 from salt.ext.tornado.httputil import HTTPInputError
 from salt.ext.tornado.escape import utf8, native_str
 from salt.ext.tornado.log import gen_log
@@ -141,6 +141,8 @@ Foo
                      'a";";.txt',
                      'a\\"b.txt',
                      'a\\b.txt',
+                     'a b.txt',
+                     'a\tb.txt',
                      ]
         for filename in filenames:
             logging.debug("trying filename %r", filename)
@@ -158,6 +160,29 @@ Foo
             self.assertEqual(file["filename"], filename)
             self.assertEqual(file["body"], b"Foo")
 
+    def test_invalid_chars(self):
+        filenames = [
+            "a\rb.txt",
+            "a\0b.txt",
+            "a\x08b.txt",
+        ]
+        for filename in filenames:
+            str_data = b'''\
+--1234
+Content-Disposition: form-data; name="files"; filename="%s"
+
+Foo
+--1234--''' % filename.replace(
+                "\\", "\\\\"
+            ).replace(
+                '"', '\\"'
+            )
+            data = utf8(str_data.replace(b"\n", b"\r\n"))
+            args, files = form_data_args()
+            with self.assertRaises(HTTPInputError) as cm:
+                parse_multipart_form_data(b"1234", data, args, files)
+            self.assertIn("Invalid header value", str(cm.exception))
+
     def test_boundary_starts_and_ends_with_quotes(self):
         data = b'''\
 --1234
@@ -264,10 +289,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={d1} vs d2={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):
diff --git a/salt/ext/tornado/test/web_test.py b/salt/ext/tornado/test/web_test.py
index 5078721f6ec..469dfc04286 100644
--- a/salt/ext/tornado/test/web_test.py
+++ b/salt/ext/tornado/test/web_test.py
@@ -21,6 +21,7 @@ import copy
 import datetime
 import email.utils
 import gzip
+import http
 from io import BytesIO
 import itertools
 import logging
@@ -206,11 +207,67 @@ class CookieTest(WebTestCase):
                                 path=u"/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")
@@ -238,6 +295,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),
@@ -290,6 +348,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/salt/ext/tornado/web.py b/salt/ext/tornado/web.py
index 568d35ac9e7..bb76abef359 100644
--- a/salt/ext/tornado/web.py
+++ b/salt/ext/tornado/web.py
@@ -528,7 +528,7 @@ class RequestHandler(object):
         return default
 
     def set_cookie(self, name, value, domain=None, expires=None, path="/",
-                   expires_days=None, **kwargs):
+                   expires_days=None, samesite=None, **kwargs):
         """Sets the given cookie name/value with the given options.
 
         Additional keyword arguments are set on the Cookie.Morsel
@@ -539,9 +539,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
+        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("Invalid cookie %r: %r" % (name, value))
+        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()). We m
+            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 = Cookie.SimpleCookie()
         if name in self._new_cookie:
@@ -557,6 +578,8 @@ class RequestHandler(object):
             morsel["expires"] = httputil.format_timestamp(expires)
         if path:
             morsel["path"] = path
+        if samesite:
+            morsel["samesite"] = samesite
         for k, v in kwargs.items():
             if k == 'max_age':
                 k = 'max-age'
-- 
2.53.0

openSUSE Build Service is sponsored by