salt/fix-cve-2024-22231-and-cve-2024-22232-bsc-1219430-bs.patch

545 lines
18 KiB
Diff

From 5710bc3ff3887762182f8326bd74f40d3872a69f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?=
<psuarezhernandez@suse.com>
Date: Thu, 1 Feb 2024 11:50:16 +0000
Subject: [PATCH] Fix "CVE-2024-22231" and "CVE-2024-22232"
(bsc#1219430, bsc#1219431) (#621)
* Fix CVE-2024-22231 and CVE-2024-22232
* Add changelogs for CVE-2024-22231 and CVE-2024-22232
* Fix linter issue
* Add credit
* Fix wart in patch
* Clean up test fixtures
* Fix test on windows
* Update changelog file name
* Fix fileroots tests
---------
Co-authored-by: Daniel A. Wozniak <dwozniak@vmware.com>
---
changelog/565.security.md | 4 +
salt/fileserver/__init__.py | 9 +-
salt/fileserver/roots.py | 26 +++++
salt/master.py | 15 ++-
tests/pytests/unit/fileserver/test_roots.py | 58 +++++++--
tests/pytests/unit/test_fileserver.py | 123 ++++++++++++++++++++
tests/pytests/unit/test_master.py | 33 ++++++
tests/unit/test_fileserver.py | 79 -------------
8 files changed, 250 insertions(+), 97 deletions(-)
create mode 100644 changelog/565.security.md
create mode 100644 tests/pytests/unit/test_fileserver.py
delete mode 100644 tests/unit/test_fileserver.py
diff --git a/changelog/565.security.md b/changelog/565.security.md
new file mode 100644
index 00000000000..5d7ec8202ba
--- /dev/null
+++ b/changelog/565.security.md
@@ -0,0 +1,4 @@
+CVE-2024-22231 Prevent directory traversal when creating syndic cache directory on the master
+CVE-2024-22232 Prevent directory traversal attacks in the master's serve_file method.
+These vulerablities were discovered and reported by:
+Yudi Zhao(Huawei Nebula Security Lab),Chenwei Jiang(Huawei Nebula Security Lab)
diff --git a/salt/fileserver/__init__.py b/salt/fileserver/__init__.py
index 99f12387f91..4eca98d14a4 100644
--- a/salt/fileserver/__init__.py
+++ b/salt/fileserver/__init__.py
@@ -568,11 +568,6 @@ class Fileserver:
saltenv = salt.utils.stringutils.to_unicode(saltenv)
back = self.backends(back)
kwargs = {}
- fnd = {"path": "", "rel": ""}
- if os.path.isabs(path):
- return fnd
- if "../" in path:
- return fnd
if salt.utils.url.is_escaped(path):
# don't attempt to find URL query arguments in the path
path = salt.utils.url.unescape(path)
@@ -588,6 +583,10 @@ class Fileserver:
args = comp.split("=", 1)
kwargs[args[0]] = args[1]
+ fnd = {"path": "", "rel": ""}
+ if os.path.isabs(path) or "../" in path:
+ return fnd
+
if "env" in kwargs:
# "env" is not supported; Use "saltenv".
kwargs.pop("env")
diff --git a/salt/fileserver/roots.py b/salt/fileserver/roots.py
index a02b597c6f8..e2ea92029c3 100644
--- a/salt/fileserver/roots.py
+++ b/salt/fileserver/roots.py
@@ -27,6 +27,7 @@ import salt.utils.hashutils
import salt.utils.path
import salt.utils.platform
import salt.utils.stringutils
+import salt.utils.verify
import salt.utils.versions
log = logging.getLogger(__name__)
@@ -98,6 +99,11 @@ def find_file(path, saltenv="base", **kwargs):
if saltenv == "__env__":
root = root.replace("__env__", actual_saltenv)
full = os.path.join(root, path)
+
+ # Refuse to serve file that is not under the root.
+ if not salt.utils.verify.clean_path(root, full, subdir=True):
+ continue
+
if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
fnd["path"] = full
fnd["rel"] = path
@@ -128,6 +134,26 @@ def serve_file(load, fnd):
ret["dest"] = fnd["rel"]
gzip = load.get("gzip", None)
fpath = os.path.normpath(fnd["path"])
+
+ actual_saltenv = saltenv = load["saltenv"]
+ if saltenv not in __opts__["file_roots"]:
+ if "__env__" in __opts__["file_roots"]:
+ log.debug(
+ "salt environment '%s' maps to __env__ file_roots directory", saltenv
+ )
+ saltenv = "__env__"
+ else:
+ return fnd
+ file_in_root = False
+ for root in __opts__["file_roots"][saltenv]:
+ if saltenv == "__env__":
+ root = root.replace("__env__", actual_saltenv)
+ # Refuse to serve file that is not under the root.
+ if salt.utils.verify.clean_path(root, fpath, subdir=True):
+ file_in_root = True
+ if not file_in_root:
+ return ret
+
with salt.utils.files.fopen(fpath, "rb") as fp_:
fp_.seek(load["loc"])
data = fp_.read(__opts__["file_buffer_size"])
diff --git a/salt/master.py b/salt/master.py
index 3d2ba1e29de..425b4121481 100644
--- a/salt/master.py
+++ b/salt/master.py
@@ -1038,7 +1038,10 @@ class MWorker(salt.utils.process.SignalHandlingProcess):
"""
key = payload["enc"]
load = payload["load"]
- ret = {"aes": self._handle_aes, "clear": self._handle_clear}[key](load)
+ if key == "aes":
+ ret = self._handle_aes(load)
+ else:
+ ret = self._handle_clear(load)
raise salt.ext.tornado.gen.Return(ret)
def _post_stats(self, start, cmd):
@@ -1213,7 +1216,7 @@ class AESFuncs(TransportMethods):
"_dir_list",
"_symlink_list",
"_file_envs",
- "_ext_nodes", # To keep compatibility with old Salt minion versions
+ "_ext_nodes", # To keep compatibility with old Salt minion versions
)
def __init__(self, opts, context=None):
@@ -1746,10 +1749,16 @@ class AESFuncs(TransportMethods):
self.mminion.returners[fstr](load["jid"], load["load"])
# Register the syndic
+
+ # We are creating a path using user suplied input. Use the
+ # clean_path to prevent a directory traversal.
+ root = os.path.join(self.opts["cachedir"], "syndics")
syndic_cache_path = os.path.join(
self.opts["cachedir"], "syndics", load["id"]
)
- if not os.path.exists(syndic_cache_path):
+ if salt.utils.verify.clean_path(
+ root, syndic_cache_path
+ ) and not os.path.exists(syndic_cache_path):
path_name = os.path.split(syndic_cache_path)[0]
if not os.path.exists(path_name):
os.makedirs(path_name)
diff --git a/tests/pytests/unit/fileserver/test_roots.py b/tests/pytests/unit/fileserver/test_roots.py
index 96bceb0fd3d..c1660280bc5 100644
--- a/tests/pytests/unit/fileserver/test_roots.py
+++ b/tests/pytests/unit/fileserver/test_roots.py
@@ -5,6 +5,7 @@
import copy
import pathlib
import shutil
+import sys
import textwrap
import pytest
@@ -28,14 +29,14 @@ def unicode_dirname():
return "соль"
-@pytest.fixture(autouse=True)
+@pytest.fixture
def testfile(tmp_path):
fp = tmp_path / "testfile"
fp.write_text("This is a testfile")
return fp
-@pytest.fixture(autouse=True)
+@pytest.fixture
def tmp_state_tree(tmp_path, testfile, unicode_filename, unicode_dirname):
dirname = tmp_path / "roots_tmp_state_tree"
dirname.mkdir(parents=True, exist_ok=True)
@@ -54,11 +55,15 @@ def tmp_state_tree(tmp_path, testfile, unicode_filename, unicode_dirname):
@pytest.fixture
-def configure_loader_modules(tmp_state_tree, temp_salt_master):
- opts = temp_salt_master.config.copy()
+def testfilepath(tmp_state_tree, testfile):
+ return tmp_state_tree / testfile.name
+
+
+@pytest.fixture
+def configure_loader_modules(tmp_state_tree, master_opts):
overrides = {"file_roots": {"base": [str(tmp_state_tree)]}}
- opts.update(overrides)
- return {roots: {"__opts__": opts}}
+ master_opts.update(overrides)
+ return {roots: {"__opts__": master_opts}}
def test_file_list(unicode_filename):
@@ -75,17 +80,17 @@ def test_find_file(tmp_state_tree):
assert full_path_to_file == ret["path"]
-def test_serve_file(testfile):
+def test_serve_file(testfilepath):
with patch.dict(roots.__opts__, {"file_buffer_size": 262144}):
load = {
"saltenv": "base",
- "path": str(testfile),
+ "path": str(testfilepath),
"loc": 0,
}
- fnd = {"path": str(testfile), "rel": "testfile"}
+ fnd = {"path": str(testfilepath), "rel": "testfile"}
ret = roots.serve_file(load, fnd)
- with salt.utils.files.fopen(str(testfile), "rb") as fp_:
+ with salt.utils.files.fopen(str(testfilepath), "rb") as fp_:
data = fp_.read()
assert ret == {"data": data, "dest": "testfile"}
@@ -277,3 +282,36 @@ def test_update_mtime_map_unicode_error(tmp_path):
},
"backend": "roots",
}
+
+
+def test_find_file_not_in_root(tmp_state_tree):
+ """
+ Fileroots should never 'find' a file that is outside of it's root.
+ """
+ badfile = pathlib.Path(tmp_state_tree).parent / "bar"
+ badfile.write_text("Bad file")
+ badpath = f"../bar"
+ ret = roots.find_file(badpath)
+ assert ret == {"path": "", "rel": ""}
+ badpath = f"{tmp_state_tree / '..' / 'bar'}"
+ ret = roots.find_file(badpath)
+ assert ret == {"path": "", "rel": ""}
+
+
+def test_serve_file_not_in_root(tmp_state_tree):
+ """
+ Fileroots should never 'serve' a file that is outside of it's root.
+ """
+ badfile = pathlib.Path(tmp_state_tree).parent / "bar"
+ badfile.write_text("Bad file")
+ badpath = f"../bar"
+ load = {"path": "salt://|..\\bar", "saltenv": "base", "loc": 0}
+ fnd = {
+ "path": f"{tmp_state_tree / '..' / 'bar'}",
+ "rel": f"{pathlib.Path('..') / 'bar'}",
+ }
+ ret = roots.serve_file(load, fnd)
+ if "win" in sys.platform:
+ assert ret == {"data": "", "dest": "..\\bar"}
+ else:
+ assert ret == {"data": "", "dest": "../bar"}
diff --git a/tests/pytests/unit/test_fileserver.py b/tests/pytests/unit/test_fileserver.py
new file mode 100644
index 00000000000..8dd3ea0a27d
--- /dev/null
+++ b/tests/pytests/unit/test_fileserver.py
@@ -0,0 +1,123 @@
+import datetime
+import os
+import time
+
+import salt.fileserver
+import salt.utils.files
+
+
+def test_diff_with_diffent_keys():
+ """
+ Test that different maps are indeed reported different
+ """
+ map1 = {"file1": 1234}
+ map2 = {"file2": 1234}
+ assert salt.fileserver.diff_mtime_map(map1, map2) is True
+
+
+def test_diff_with_diffent_values():
+ """
+ Test that different maps are indeed reported different
+ """
+ map1 = {"file1": 12345}
+ map2 = {"file1": 1234}
+ assert salt.fileserver.diff_mtime_map(map1, map2) is True
+
+
+def test_whitelist():
+ opts = {
+ "fileserver_backend": ["roots", "git", "s3fs", "hgfs", "svn"],
+ "extension_modules": "",
+ }
+ fs = salt.fileserver.Fileserver(opts)
+ assert sorted(fs.servers.whitelist) == sorted(
+ ["git", "gitfs", "hg", "hgfs", "svn", "svnfs", "roots", "s3fs"]
+ ), fs.servers.whitelist
+
+
+def test_future_file_list_cache_file_ignored(tmp_path):
+ opts = {
+ "fileserver_backend": ["roots"],
+ "cachedir": tmp_path,
+ "extension_modules": "",
+ }
+
+ back_cachedir = os.path.join(tmp_path, "file_lists/roots")
+ os.makedirs(os.path.join(back_cachedir))
+
+ # Touch a couple files
+ for filename in ("base.p", "foo.txt"):
+ with salt.utils.files.fopen(os.path.join(back_cachedir, filename), "wb") as _f:
+ if filename == "base.p":
+ _f.write(b"\x80")
+
+ # Set modification time to file list cache file to 1 year in the future
+ now = datetime.datetime.utcnow()
+ future = now + datetime.timedelta(days=365)
+ mod_time = time.mktime(future.timetuple())
+ os.utime(os.path.join(back_cachedir, "base.p"), (mod_time, mod_time))
+
+ list_cache = os.path.join(back_cachedir, "base.p")
+ w_lock = os.path.join(back_cachedir, ".base.w")
+ ret = salt.fileserver.check_file_list_cache(opts, "files", list_cache, w_lock)
+ assert (
+ ret[1] is True
+ ), "Cache file list cache file is not refreshed when future modification time"
+
+
+def test_file_server_url_escape(tmp_path):
+ (tmp_path / "srv").mkdir()
+ (tmp_path / "srv" / "salt").mkdir()
+ (tmp_path / "foo").mkdir()
+ (tmp_path / "foo" / "bar").write_text("Bad file")
+ fileroot = str(tmp_path / "srv" / "salt")
+ badfile = str(tmp_path / "foo" / "bar")
+ opts = {
+ "fileserver_backend": ["roots"],
+ "extension_modules": "",
+ "optimization_order": [
+ 0,
+ ],
+ "file_roots": {
+ "base": [fileroot],
+ },
+ "file_ignore_regex": "",
+ "file_ignore_glob": "",
+ }
+ fs = salt.fileserver.Fileserver(opts)
+ ret = fs.find_file(
+ "salt://|..\\..\\..\\foo/bar",
+ "base",
+ )
+ assert ret == {"path": "", "rel": ""}
+
+
+def test_file_server_serve_url_escape(tmp_path):
+ (tmp_path / "srv").mkdir()
+ (tmp_path / "srv" / "salt").mkdir()
+ (tmp_path / "foo").mkdir()
+ (tmp_path / "foo" / "bar").write_text("Bad file")
+ fileroot = str(tmp_path / "srv" / "salt")
+ badfile = str(tmp_path / "foo" / "bar")
+ opts = {
+ "fileserver_backend": ["roots"],
+ "extension_modules": "",
+ "optimization_order": [
+ 0,
+ ],
+ "file_roots": {
+ "base": [fileroot],
+ },
+ "file_ignore_regex": "",
+ "file_ignore_glob": "",
+ "file_buffer_size": 2048,
+ }
+ fs = salt.fileserver.Fileserver(opts)
+ ret = fs.serve_file(
+ {
+ "path": "salt://|..\\..\\..\\foo/bar",
+ "saltenv": "base",
+ "loc": 0,
+ }
+ )
+ assert ret == {"data": "", "dest": ""}
diff --git a/tests/pytests/unit/test_master.py b/tests/pytests/unit/test_master.py
index 98c796912aa..d338307d1f8 100644
--- a/tests/pytests/unit/test_master.py
+++ b/tests/pytests/unit/test_master.py
@@ -1,3 +1,4 @@
+import pathlib
import time
import pytest
@@ -249,3 +250,35 @@ def test_mworker_pass_context():
loadler_pillars_mock.call_args_list[0][1].get("pack").get("__context__")
== test_context
)
+
+
+def test_syndic_return_cache_dir_creation(encrypted_requests):
+ """master's cachedir for a syndic will be created by AESFuncs._syndic_return method"""
+ cachedir = pathlib.Path(encrypted_requests.opts["cachedir"])
+ assert not (cachedir / "syndics").exists()
+ encrypted_requests._syndic_return(
+ {
+ "id": "mamajama",
+ "jid": "",
+ "return": {},
+ }
+ )
+ assert (cachedir / "syndics").exists()
+ assert (cachedir / "syndics" / "mamajama").exists()
+
+
+def test_syndic_return_cache_dir_creation_traversal(encrypted_requests):
+ """
+ master's AESFuncs._syndic_return method cachdir creation is not vulnerable to a directory traversal
+ """
+ cachedir = pathlib.Path(encrypted_requests.opts["cachedir"])
+ assert not (cachedir / "syndics").exists()
+ encrypted_requests._syndic_return(
+ {
+ "id": "../mamajama",
+ "jid": "",
+ "return": {},
+ }
+ )
+ assert not (cachedir / "syndics").exists()
+ assert not (cachedir / "mamajama").exists()
diff --git a/tests/unit/test_fileserver.py b/tests/unit/test_fileserver.py
deleted file mode 100644
index c290b16b7e4..00000000000
--- a/tests/unit/test_fileserver.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
- :codeauthor: Joao Mesquita <jmesquita@sangoma.com>
-"""
-
-
-import datetime
-import os
-import time
-
-import salt.utils.files
-from salt import fileserver
-from tests.support.helpers import with_tempdir
-from tests.support.mixins import LoaderModuleMockMixin
-from tests.support.unit import TestCase
-
-
-class MapDiffTestCase(TestCase):
- def test_diff_with_diffent_keys(self):
- """
- Test that different maps are indeed reported different
- """
- map1 = {"file1": 1234}
- map2 = {"file2": 1234}
- assert fileserver.diff_mtime_map(map1, map2) is True
-
- def test_diff_with_diffent_values(self):
- """
- Test that different maps are indeed reported different
- """
- map1 = {"file1": 12345}
- map2 = {"file1": 1234}
- assert fileserver.diff_mtime_map(map1, map2) is True
-
-
-class VCSBackendWhitelistCase(TestCase, LoaderModuleMockMixin):
- def setup_loader_modules(self):
- return {fileserver: {}}
-
- def test_whitelist(self):
- opts = {
- "fileserver_backend": ["roots", "git", "s3fs", "hgfs", "svn"],
- "extension_modules": "",
- }
- fs = fileserver.Fileserver(opts)
- assert sorted(fs.servers.whitelist) == sorted(
- ["git", "gitfs", "hg", "hgfs", "svn", "svnfs", "roots", "s3fs"]
- ), fs.servers.whitelist
-
- @with_tempdir()
- def test_future_file_list_cache_file_ignored(self, cachedir):
- opts = {
- "fileserver_backend": ["roots"],
- "cachedir": cachedir,
- "extension_modules": "",
- }
-
- back_cachedir = os.path.join(cachedir, "file_lists/roots")
- os.makedirs(os.path.join(back_cachedir))
-
- # Touch a couple files
- for filename in ("base.p", "foo.txt"):
- with salt.utils.files.fopen(
- os.path.join(back_cachedir, filename), "wb"
- ) as _f:
- if filename == "base.p":
- _f.write(b"\x80")
-
- # Set modification time to file list cache file to 1 year in the future
- now = datetime.datetime.utcnow()
- future = now + datetime.timedelta(days=365)
- mod_time = time.mktime(future.timetuple())
- os.utime(os.path.join(back_cachedir, "base.p"), (mod_time, mod_time))
-
- list_cache = os.path.join(back_cachedir, "base.p")
- w_lock = os.path.join(back_cachedir, ".base.w")
- ret = fileserver.check_file_list_cache(opts, "files", list_cache, w_lock)
- assert (
- ret[1] is True
- ), "Cache file list cache file is not refreshed when future modification time"
--
2.43.0