Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package salt for openSUSE:Factory checked in 
at 2026-03-18 16:49:19
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/salt (Old)
 and      /work/SRC/openSUSE:Factory/.salt.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "salt"

Wed Mar 18 16:49:19 2026 rev:192 rq:1340716 version:3006.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/salt/salt.changes        2026-01-28 
15:09:16.931324393 +0100
+++ /work/SRC/openSUSE:Factory/.salt.new.8177/salt.changes      2026-03-18 
16:49:42.619030984 +0100
@@ -1,0 +2,23 @@
+Tue Mar 17 11:02:24 UTC 2026 - Victor Zhestkov <[email protected]>
+
+- Backport security patch for Salt vendored tornado (bsc#1259554):
+  * CVE-2026-31958: Add limits on multipart form data parsing
+
+- Added:
+  * backport-of-the-cve-2026-31958-fix-bsc-1259554.patch
+
+-------------------------------------------------------------------
+Fri Mar 13 14:11:34 UTC 2026 - Victor Zhestkov <[email protected]>
+
+- Add x86_64_v2 as a possible rpm package architecture
+- Make users with backslash working for salt-ssh (bsc#1254629)
+- Fix ansible.playbooks extra-vars quoting (bsc#1257831)
+- Fix virtualenv call in test helper to use proper python version
+
+- Added:
+  * add-x86_64_v2-as-a-possible-rpm-package-architecture.patch
+  * make-users-with-backslash-working-for-salt-ssh-bsc-1.patch
+  * fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
+  * fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch
+
+-------------------------------------------------------------------
@@ -19,0 +43 @@
+- CVE-2025-13836: limit http.client response read in NXOS modules

New:
----
  add-x86_64_v2-as-a-possible-rpm-package-architecture.patch
  backport-of-the-cve-2026-31958-fix-bsc-1259554.patch
  fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
  fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch
  make-users-with-backslash-working-for-salt-ssh-bsc-1.patch

----------(New B)----------
  New:- Added:
  * add-x86_64_v2-as-a-possible-rpm-package-architecture.patch
  * make-users-with-backslash-working-for-salt-ssh-bsc-1.patch
  New:- Added:
  * backport-of-the-cve-2026-31958-fix-bsc-1259554.patch
  New:  * make-users-with-backslash-working-for-salt-ssh-bsc-1.patch
  * fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
  * fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch
  New:  * fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
  * fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch
  New:  * add-x86_64_v2-as-a-possible-rpm-package-architecture.patch
  * make-users-with-backslash-working-for-salt-ssh-bsc-1.patch
  * fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
----------(New E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ salt.spec ++++++
--- /var/tmp/diff_new_pack.D3vxAq/_old  2026-03-18 16:49:52.899461612 +0100
+++ /var/tmp/diff_new_pack.D3vxAq/_new  2026-03-18 16:49:52.903461780 +0100
@@ -615,7 +615,17 @@
 Patch194:       backport-add-maintain-m-privilege-to-postgres-module.patch
 # PATCH-FIX_OPENSUSE: https://github.com/openSUSE/salt/pull/745
 Patch195:       fix-tornado-s-httputil_test-syntax-for-python-3.6.patch
-
+# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/68494
+Patch196:       fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch
+# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/68787
+Patch197:       fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch
+# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/68790
+Patch198:       make-users-with-backslash-working-for-salt-ssh-bsc-1.patch
+# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/68789
+Patch199:       add-x86_64_v2-as-a-possible-rpm-package-architecture.patch
+# PATCH-FIX_UPSTREAM: https://github.com/tornadoweb/tornado/pull/3584
+# PATCH-FIX_OPENSUSE: https://github.com/openSUSE/salt/pull/750
+Patch200:       backport-of-the-cve-2026-31958-fix-bsc-1259554.patch
 
 ### IMPORTANT: The line below is used as a snippet marker. Do not touch it.
 ### SALT PATCHES LIST END

++++++ _lastrevision ++++++
--- /var/tmp/diff_new_pack.D3vxAq/_old  2026-03-18 16:49:53.031467142 +0100
+++ /var/tmp/diff_new_pack.D3vxAq/_new  2026-03-18 16:49:53.035467310 +0100
@@ -1,2 +1,3 @@
-b9f7b17d7248f80ac48596f6347fb328bd11c402
+2bafa6ca6d63750ac4a2bfd25fb28d1e52e989b7
+(No newline at EOF)
 

++++++ add-x86_64_v2-as-a-possible-rpm-package-architecture.patch ++++++
>From d2564c52e7b53251f62d1f997bffcd63a76b6c65 Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <[email protected]>
Date: Fri, 13 Mar 2026 14:38:21 +0100
Subject: [PATCH] Add `x86_64_v2` as a possible rpm package
 architecture

* Add x86_64_v2 as a possible rpm architecture

and make it resolving the right way in case of mixing with x86_64

* Reuse existing logic from salt.utils.pkg.rpm.resolve_name

for salt.modules.yumpkg.normalize_name

* Extend the tests if x86_64_v2 package arch can be handled
---
 salt/modules/yumpkg.py                    |  7 +++----
 salt/utils/pkg/rpm.py                     | 11 ++++++++---
 tests/pytests/unit/modules/test_yumpkg.py | 16 ++++++++++++++++
 3 files changed, 27 insertions(+), 7 deletions(-)

diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py
index b2be251a408..4c9330231e1 100644
--- a/salt/modules/yumpkg.py
+++ b/salt/modules/yumpkg.py
@@ -473,10 +473,9 @@ def normalize_name(name):
             return name
     except ValueError:
         return name
-    if arch in (__grains__["osarch"], "noarch") or salt.utils.pkg.rpm.check_32(
-        arch, osarch=__grains__["osarch"]
-    ):
-        return name[: -(len(arch) + 1)]
+    stripped_name = name[: -(len(arch) + 1)]
+    if salt.utils.pkg.rpm.resolve_name(stripped_name, arch) == stripped_name:
+        return stripped_name
     return name
 
 
diff --git a/salt/utils/pkg/rpm.py b/salt/utils/pkg/rpm.py
index 147447ba757..2a4e26cbe71 100644
--- a/salt/utils/pkg/rpm.py
+++ b/salt/utils/pkg/rpm.py
@@ -13,7 +13,8 @@ import salt.utils.stringutils
 log = logging.getLogger(__name__)
 
 # These arches compiled from the rpmUtils.arch python module source
-ARCHES_64 = ("x86_64", "athlon", "amd64", "ia32e", "ia64", "geode")
+ARCHES_X86_64 = ("x86_64", "x86_64_v2")
+ARCHES_64 = ARCHES_X86_64 + ("athlon", "amd64", "ia32e", "ia64", "geode")
 ARCHES_32 = ("i386", "i486", "i586", "i686")
 ARCHES_PPC = ("ppc", "ppc64", "ppc64le", "ppc64iseries", "ppc64pseries")
 ARCHES_S390 = ("s390", "s390x")
@@ -105,8 +106,12 @@ def resolve_name(name, arch, osarch=None):
     if osarch is None:
         osarch = get_osarch()
 
-    if not check_32(arch, osarch) and arch not in (osarch, "noarch"):
-        name += ".{}".format(arch)
+    if (
+        not check_32(arch, osarch)
+        and arch not in (osarch, "noarch")
+        and not (arch in ARCHES_X86_64 and osarch in ARCHES_X86_64)
+    ):
+        name += f".{arch}"
     return name
 
 
diff --git a/tests/pytests/unit/modules/test_yumpkg.py 
b/tests/pytests/unit/modules/test_yumpkg.py
index 45c62d793d3..286c985bbb7 100644
--- a/tests/pytests/unit/modules/test_yumpkg.py
+++ b/tests/pytests/unit/modules/test_yumpkg.py
@@ -2150,3 +2150,19 @@ def 
test_59705_version_as_accidental_float_should_become_text(
         yumpkg.install("fnord", version=new)
         call = cmd_mock.mock_calls[0][1][0]
         assert call == expected_cmd
+
+
+def test_normalize_name_with_arch_x86_64_v2():
+    """
+    Test if `salt.modules.yumpkg.normalize_name` is able to identify x86_64_v2
+    as a possible package architecture and remove it from name in case of
+    using it on x86_64 and not in any other cases.
+    """
+    with patch("salt.utils.pkg.rpm.get_osarch", 
MagicMock(return_value="x86_64")):
+        assert yumpkg.normalize_name("chrony.x86_64_v2") == "chrony"
+    with patch("salt.utils.pkg.rpm.get_osarch", 
MagicMock(return_value="x86_64")):
+        assert yumpkg.normalize_name("chrony.x86_64") == "chrony"
+    with patch("salt.utils.pkg.rpm.get_osarch", 
MagicMock(return_value="amd64")):
+        assert yumpkg.normalize_name("chrony.x86_64") == "chrony.x86_64"
+    with patch("salt.utils.pkg.rpm.get_osarch", 
MagicMock(return_value="x86_64")):
+        assert yumpkg.normalize_name("rootfiles.noarch") == "rootfiles"
-- 
2.53.0


++++++ backport-of-the-cve-2026-31958-fix-bsc-1259554.patch ++++++
>From 49f0b8d91c472ccae78abe57683253f00530e9d2 Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <[email protected]>
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 <[email protected]>
---
 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


++++++ fix-ansible.playbooks-extra-vars-quoting-bsc-1257831.patch ++++++
>From ec032815e3748e34ebd4848d097de4a871828214 Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <[email protected]>
Date: Fri, 13 Mar 2026 14:36:12 +0100
Subject: [PATCH] Fix ansible.playbooks extra-vars quoting
 (bsc#1257831)

* Fix ansible.playbooks extra-vars quoting (bsc#1257831)

* Add test for passing complex extra-vars to ansible.playbooks
---
 salt/modules/ansiblegate.py                   |  5 ++--
 .../pytests/unit/modules/test_ansiblegate.py  | 30 +++++++++++++++++++
 2 files changed, 33 insertions(+), 2 deletions(-)

diff --git a/salt/modules/ansiblegate.py b/salt/modules/ansiblegate.py
index 9dd878665f0..8d6a80b930a 100644
--- a/salt/modules/ansiblegate.py
+++ b/salt/modules/ansiblegate.py
@@ -18,6 +18,7 @@ import fnmatch
 import json
 import logging
 import os
+import shlex
 import subprocess
 import sys
 from tempfile import NamedTemporaryFile
@@ -367,9 +368,9 @@ def playbooks(
     if diff:
         command.append("--diff")
     if isinstance(extra_vars, dict):
-        command.append(f"--extra-vars='{json.dumps(extra_vars)}'")
+        command.append(f"--extra-vars={shlex.quote(json.dumps(extra_vars))}")
     elif isinstance(extra_vars, str) and extra_vars.startswith("@"):
-        command.append(f"--extra-vars={extra_vars}")
+        command.append(f"--extra-vars={shlex.quote(extra_vars)}")
     if flush_cache:
         command.append("--flush-cache")
     if inventory:
diff --git a/tests/pytests/unit/modules/test_ansiblegate.py 
b/tests/pytests/unit/modules/test_ansiblegate.py
index d8bdd1140ea..b12b0bea4e1 100644
--- a/tests/pytests/unit/modules/test_ansiblegate.py
+++ b/tests/pytests/unit/modules/test_ansiblegate.py
@@ -1,6 +1,8 @@
 # Author: Bo Maryniuk <[email protected]>
 
+import json
 import os
+import shlex
 
 import pytest
 
@@ -351,3 +353,31 @@ def test_ansible_discover_playbooks_multiple_locations():
         "fullpath": os.path.join(playbooks_dir, "example-playbook2/site.yml"),
         "custom_inventory": os.path.join(playbooks_dir, 
"example-playbook2/hosts"),
     }
+
+
+def test_ansible_playbooks_with_complex_extra_vars():
+    """
+    Test ansible.playbooks execution module function can pass comples 
extra-vars.
+    """
+    extra_vars = {
+        "test_key1": {
+            "test_subkey1": "single'quote",
+            "test_subkey2": 'double"quote',
+        },
+        "test_key2": {
+            "backquote": "`",
+        },
+    }
+    cmd_run_all = MagicMock(return_value={"retcode": 0, "stdout": '{"foo": 
"bar"}'})
+    with patch.dict(ansiblegate.__salt__, {"cmd.run_all": cmd_run_all}), patch(
+        "salt.utils.path.which", MagicMock(return_value=True)
+    ):
+        ret = ansiblegate.playbooks("fake-playbook.yml", extra_vars=extra_vars)
+        assert "retcode" in ret
+        cmd_run_all.assert_called_once()
+        passed_extra_vars = {}
+        for arg in shlex.split(cmd_run_all.call_args.kwargs["cmd"]):
+            if arg.startswith("--extra-vars="):
+                passed_extra_vars = arg[13:]
+        passed_extra_vars = json.loads(passed_extra_vars)
+        assert passed_extra_vars == extra_vars
-- 
2.53.0


++++++ fix-virtualenv-call-in-test-helper-to-use-proper-pyt.patch ++++++
>From bd8b0bfecc36af97bc85db67fe14594f56c1344f Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <[email protected]>
Date: Fri, 13 Mar 2026 12:22:50 +0100
Subject: [PATCH] Fix virtualenv call in test helper to use proper
 python version

---
 tests/support/helpers.py | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/tests/support/helpers.py b/tests/support/helpers.py
index 5dc4041ff4d..21bd35c69dd 100644
--- a/tests/support/helpers.py
+++ b/tests/support/helpers.py
@@ -1742,12 +1742,14 @@ class VirtualEnv:
         return data
 
     def _create_virtualenv(self):
-        virtualenv = shutil.which("virtualenv")
-        if not virtualenv:
-            pytest.fail("'virtualenv' binary not found")
+        pyexec = self.get_real_python()
+        if not pyexec:
+            pytest.fail("'python' binary not found for virtualenv")
         cmd = [
-            virtualenv,
-            "--python={}".format(self.get_real_python()),
+            pyexec,
+            "-m",
+            "virtualenv",
+            f"--python={pyexec}",
         ]
         if self.system_site_packages:
             cmd.append("--system-site-packages")
-- 
2.53.0


++++++ make-users-with-backslash-working-for-salt-ssh-bsc-1.patch ++++++
>From d1103f2beaed2b6ca82ceb8ca0fa67f1888af319 Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <[email protected]>
Date: Fri, 13 Mar 2026 14:37:07 +0100
Subject: [PATCH] Make users with backslash working for salt-ssh
 (bsc#1254629)

* Make users with backslash working for salt-ssh (bsc#1254629)

* Add tests for ssh user with backslash
---
 salt/client/ssh/__init__.py                  |  5 ++++-
 salt/client/ssh/shell.py                     |  4 ++--
 tests/pytests/unit/client/ssh/test_shell.py  | 15 +++++++++++++++
 tests/pytests/unit/client/ssh/test_single.py | 18 ++++++++++++++++++
 4 files changed, 39 insertions(+), 3 deletions(-)

diff --git a/salt/client/ssh/__init__.py b/salt/client/ssh/__init__.py
index 86e4bcceb08..6025b76c3b6 100644
--- a/salt/client/ssh/__init__.py
+++ b/salt/client/ssh/__init__.py
@@ -1059,7 +1059,10 @@ class Single:
             self.python_env = kwargs.get("ssh_python_env")
         else:
             if user:
-                thin_dir = DEFAULT_THIN_DIR.replace("%%USER%%", user)
+                thin_dir = DEFAULT_THIN_DIR.replace(
+                    "%%USER%%",
+                    re.sub(r"[^a-zA-Z0-9\._\-@]", "_", user),
+                )
             else:
                 thin_dir = DEFAULT_THIN_DIR.replace("%%USER%%", "root")
             self.thin_dir = thin_dir.replace(
diff --git a/salt/client/ssh/shell.py b/salt/client/ssh/shell.py
index fcacfa6f737..fa6e9ed637d 100644
--- a/salt/client/ssh/shell.py
+++ b/salt/client/ssh/shell.py
@@ -151,7 +151,7 @@ class Shell:
         if self.priv and self.priv != "agent-forwarding":
             options.append("IdentityFile={}".format(self.priv))
         if self.user:
-            options.append("User={}".format(self.user))
+            options.append("User={}".format(shlex.quote(self.user)))
         if self.identities_only:
             options.append("IdentitiesOnly=yes")
 
@@ -197,7 +197,7 @@ class Shell:
         if self.port:
             options.append("Port={}".format(self.port))
         if self.user:
-            options.append("User={}".format(self.user))
+            options.append("User={}".format(shlex.quote(self.user)))
         if self.identities_only:
             options.append("IdentitiesOnly=yes")
 
diff --git a/tests/pytests/unit/client/ssh/test_shell.py 
b/tests/pytests/unit/client/ssh/test_shell.py
index 37065c4c187..66746b347b9 100644
--- a/tests/pytests/unit/client/ssh/test_shell.py
+++ b/tests/pytests/unit/client/ssh/test_shell.py
@@ -52,3 +52,18 @@ def test_ssh_shell_exec_cmd(caplog):
         ret = _shell.exec_cmd("ls {}".format(passwd))
         assert not any([x for x in ret if passwd in str(x)])
         assert passwd not in caplog.text
+
+
+def test_ssh_using_user_with_backslash():
+    _shell = shell.Shell(
+        opts={"_ssh_version": (4, 9)},
+        host="host.example.org",
+        user="exampledomain\\user",
+        passwd="password",
+    )
+    with patch.object(
+        _shell, "_run_cmd", return_value=(None, None, None)
+    ) as mock_run_cmd:
+        cmd_string = _shell.exec_cmd("whoami")
+        args, _ = mock_run_cmd.call_args
+        assert " User='exampledomain\\user' " in args[0]
diff --git a/tests/pytests/unit/client/ssh/test_single.py 
b/tests/pytests/unit/client/ssh/test_single.py
index 8d87da8700c..0cc05922e63 100644
--- a/tests/pytests/unit/client/ssh/test_single.py
+++ b/tests/pytests/unit/client/ssh/test_single.py
@@ -871,3 +871,21 @@ def test_ssh_single__cmd_str_sudo_passwd_user(opts):
     )
 
     assert expected in cmd
+
+
+def test_check_thin_dir_with_backslash_user(opts):
+    """
+    Test `thin_dir` path generation for the user with backslash in the name
+    """
+    single = ssh.Single(
+        opts,
+        opts["argv"],
+        "host.example.org",
+        "host.example.org",
+        user="exampledomain\\user",
+        mods={},
+        fsclient=None,
+        mine=False,
+    )
+    assert single.thin_dir == single.opts["thin_dir"]
+    assert ".exampledomain_user_" in single.thin_dir
-- 
2.53.0

Reply via email to