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