High severity7.7NVD Advisory· Published Jun 27, 2024· Updated Apr 15, 2026
CVE-2024-22232
CVE-2024-22232
Description
A specially crafted url can be created which leads to a directory traversal in the salt file server. A malicious user can read an arbitrary file from a Salt master’s filesystem.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
saltPyPI | < 3005.5 | 3005.5 |
saltPyPI | >= 3006.0, < 3006.6 | 3006.6 |
Patches
17 files changed · +241 −91
salt/fileserver/__init__.py+4 −5 modified@@ -568,11 +568,6 @@ def find_file(self, path, saltenv, back=None): 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 @@ def find_file(self, path, saltenv, back=None): 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")
salt/fileserver/roots.py+26 −0 modified@@ -27,6 +27,7 @@ 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 _add_file_stat(fnd): 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"])
salt/master.py+11 −2 modified@@ -1036,7 +1036,10 @@ def _handle_payload(self, payload): """ 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): @@ -1738,10 +1741,16 @@ def _syndic_return(self, load): 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)
tests/pytests/unit/fileserver/test_roots.py+40 −5 modified@@ -53,6 +53,11 @@ def tmp_state_tree(tmp_path, testfile, unicode_filename, unicode_dirname): return dirname +@pytest.fixture(autouse=True) +def testfilepath(tmp_state_tree, testfile): + return tmp_state_tree / testfile.name + + @pytest.fixture def configure_loader_modules(tmp_state_tree, temp_salt_master): opts = temp_salt_master.config.copy() @@ -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"} @@ -236,7 +241,7 @@ def test_update_mtime_map(): # between Python releases. lines_written = sorted(mtime_map_mock.write_calls()) expected = sorted( - salt.utils.stringutils.to_bytes("{key}:{val}\n".format(key=key, val=val)) + salt.utils.stringutils.to_bytes(f"{key}:{val}\n") for key, val in new_mtime_map.items() ) assert lines_written == expected, lines_written @@ -277,3 +282,33 @@ 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) + assert ret == {"data": "", "dest": "../bar"}
tests/pytests/unit/test_fileserver.py+127 −0 added@@ -0,0 +1,127 @@ +""" +""" + + +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": ""}
tests/pytests/unit/test_master.py+33 −0 modified@@ -1,3 +1,4 @@ +import pathlib import time import pytest @@ -160,3 +161,35 @@ def test_when_syndic_return_processes_load_then_correct_values_should_be_returne with patch.object(encrypted_requests, "_return", autospec=True) as fake_return: encrypted_requests._syndic_return(payload) fake_return.assert_called_with(expected_return) + + +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()
tests/unit/test_fileserver.py+0 −79 removed@@ -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"
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-2qw3-2wv6-p64xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-22232ghsaADVISORY
- github.com/saltstack/salt/commit/e0cdb80b55123f4a024759ffcf2b3f0e0788e7abghsaWEB
- saltproject.io/security-announcements/2024-01-31-advisoryghsaWEB
- saltproject.io/security-announcements/2024-01-31-advisory/nvd
News mentions
0No linked articles in our index yet.