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
