From 699108408515c7b8e90c4fcff8599f44662ceedc Mon Sep 17 00:00:00 2001
From: Jochen Breuer <jbreuer@suse.de>
Date: Tue, 28 Apr 2020 10:21:27 +0200
Subject: [PATCH] Fix CVE-2020-11651 and Fix CVE-2020-11652

---
 salt/master.py                               |  58 ++++-
 salt/tokens/localfs.py                       |   3 +
 salt/utils/verify.py                         |  57 ++++-
 salt/wheel/config.py                         |   8 +-
 salt/wheel/file_roots.py                     |   7 +-
 tests/integration/master/test_clear_funcs.py | 310 +++++++++++++++++++++++++++
 tests/unit/test_master.py                    |  25 +++
 tests/unit/test_module_names.py              |   1 +
 tests/unit/utils/test_verify.py              |  80 +++++++
 tests/whitelist.txt                          |   1 +
 10 files changed, 534 insertions(+), 16 deletions(-)
 create mode 100644 tests/integration/master/test_clear_funcs.py

diff --git a/salt/master.py b/salt/master.py
index 0e4bba0505..7cb7ff58bb 100644
--- a/salt/master.py
+++ b/salt/master.py
@@ -1088,12 +1088,13 @@ class MWorker(salt.utils.process.SignalHandlingMultiprocessingProcess):
         '''
         log.trace('Clear payload received with command %s', load['cmd'])
         cmd = load['cmd']
-        if cmd.startswith('__'):
-            return False
+        method = self.clear_funcs.get_method(cmd)
+        if not method:
+            return {}, {'fun': 'send_clear'}
         if self.opts['master_stats']:
             start = time.time()
             self.stats[cmd]['runs'] += 1
-        ret = getattr(self.clear_funcs, cmd)(load), {'fun': 'send_clear'}
+        ret = method(load), {'fun': 'send_clear'}
         if self.opts['master_stats']:
             self._post_stats(start, cmd)
         return ret
@@ -1111,8 +1112,9 @@ class MWorker(salt.utils.process.SignalHandlingMultiprocessingProcess):
             return {}
         cmd = data['cmd']
         log.trace('AES payload received with command %s', data['cmd'])
-        if cmd.startswith('__'):
-            return False
+        method = self.aes_funcs.get_method(cmd)
+        if not method:
+            return {}, {'fun': 'send'}
         if self.opts['master_stats']:
             start = time.time()
             self.stats[cmd]['runs'] += 1
@@ -1143,13 +1145,44 @@ class MWorker(salt.utils.process.SignalHandlingMultiprocessingProcess):
         self.__bind()
 
 
+class TransportMethods(object):
+    '''
+    Expose methods to the transport layer, methods with their names found in
+    the class attribute 'expose_methods' will be exposed to the transport layer
+    via 'get_method'.
+    '''
+
+    expose_methods = ()
+
+    def get_method(self, name):
+        '''
+        Get a method which should be exposed to the transport layer
+        '''
+        if name in self.expose_methods:
+            try:
+                return getattr(self, name)
+            except AttributeError:
+                log.error("Expose method not found: %s", name)
+        else:
+            log.error("Requested method not exposed: %s", name)
+
+
 # TODO: rename? No longer tied to "AES", just "encrypted" or "private" requests
-class AESFuncs(object):
+class AESFuncs(TransportMethods):
     '''
     Set up functions that are available when the load is encrypted with AES
     '''
-    # The AES Functions:
-    #
+
+    expose_methods = (
+        'verify_minion', '_master_tops', '_ext_nodes', '_master_opts',
+        '_mine_get', '_mine', '_mine_delete', '_mine_flush', '_file_recv',
+        '_pillar', '_minion_event', '_handle_minion_event', '_return',
+        '_syndic_return', '_minion_runner', 'pub_ret', 'minion_pub',
+        'minion_publish', 'revoke_auth', 'run_func', '_serve_file',
+        '_file_find', '_file_hash', '_file_find_and_stat', '_file_list',
+        '_file_list_emptydirs', '_dir_list', '_symlink_list', '_file_envs',
+    )
+
     def __init__(self, opts):
         '''
         Create a new AESFuncs
@@ -1863,11 +1896,18 @@ class AESFuncs(object):
         return ret, {'fun': 'send'}
 
 
-class ClearFuncs(object):
+class ClearFuncs(TransportMethods):
     '''
     Set up functions that are safe to execute when commands sent to the master
     without encryption and authentication
     '''
+
+    # These methods will be exposed to the transport layer by
+    # MWorker._handle_clear
+    expose_methods = (
+        'ping', 'publish', 'get_token', 'mk_token', 'wheel', 'runner',
+    )
+
     # The ClearFuncs object encapsulates the functions that can be executed in
     # the clear:
     # publish (The publish from the LocalClient)
diff --git a/salt/tokens/localfs.py b/salt/tokens/localfs.py
index 3660ee3186..747f8eea1e 100644
--- a/salt/tokens/localfs.py
+++ b/salt/tokens/localfs.py
@@ -12,6 +12,7 @@ import logging
 
 import salt.utils.files
 import salt.utils.path
+import salt.utils.verify
 import salt.payload
 
 from salt.ext import six
@@ -61,6 +62,8 @@ def get_token(opts, tok):
     :returns: Token data if successful. Empty dict if failed.
     '''
     t_path = os.path.join(opts['token_dir'], tok)
+    if not salt.utils.verify.clean_path(opts['token_dir'], t_path):
+        return {}
     if not os.path.isfile(t_path):
         return {}
     serial = salt.payload.Serial(opts)
diff --git a/salt/utils/verify.py b/salt/utils/verify.py
index 5eb8481069..f289b65b4c 100644
--- a/salt/utils/verify.py
+++ b/salt/utils/verify.py
@@ -31,6 +31,7 @@ import salt.utils.files
 import salt.utils.path
 import salt.utils.platform
 import salt.utils.user
+import salt.ext.six
 
 log = logging.getLogger(__name__)
 
@@ -472,23 +473,69 @@ def check_max_open_files(opts):
     log.log(level=level, msg=msg)
 
 
+def _realpath_darwin(path):
+    base = ''
+    for part in path.split(os.path.sep)[1:]:
+        if base != '':
+            if os.path.islink(os.path.sep.join([base, part])):
+                base = os.readlink(os.path.sep.join([base, part]))
+            else:
+                base = os.path.abspath(os.path.sep.join([base, part]))
+        else:
+            base = os.path.abspath(os.path.sep.join([base, part]))
+    return base
+
+
+def _realpath_windows(path):
+    base = ''
+    for part in path.split(os.path.sep):
+        if base != '':
+            try:
+                part = os.readlink(os.path.sep.join([base, part]))
+                base = os.path.abspath(part)
+            except OSError:
+                base = os.path.abspath(os.path.sep.join([base, part]))
+        else:
+            base = part
+    return base
+
+
+def _realpath(path):
+    '''
+    Cross platform realpath method. On Windows when python 3, this method
+    uses the os.readlink method to resolve any filesystem links. On Windows
+    when python 2, this method is a no-op. All other platforms and version use
+    os.realpath
+    '''
+    if salt.utils.platform.is_darwin():
+        return _realpath_darwin(path)
+    elif salt.utils.platform.is_windows():
+        if salt.ext.six.PY3:
+            return _realpath_windows(path)
+        else:
+            return path
+    return os.path.realpath(path)
+
+
 def clean_path(root, path, subdir=False):
     '''
     Accepts the root the path needs to be under and verifies that the path is
     under said root. Pass in subdir=True if the path can result in a
     subdirectory of the root instead of having to reside directly in the root
     '''
-    if not os.path.isabs(root):
+    real_root = _realpath(root)
+    if not os.path.isabs(real_root):
         return ''
     if not os.path.isabs(path):
         path = os.path.join(root, path)
     path = os.path.normpath(path)
+    real_path = _realpath(path)
     if subdir:
-        if path.startswith(root):
-            return path
+        if real_path.startswith(real_root):
+            return real_path
     else:
-        if os.path.dirname(path) == os.path.normpath(root):
-            return path
+        if os.path.dirname(real_path) == os.path.normpath(real_root):
+            return real_path
     return ''
 
 
diff --git a/salt/wheel/config.py b/salt/wheel/config.py
index a8a93c53e5..3984444f8f 100644
--- a/salt/wheel/config.py
+++ b/salt/wheel/config.py
@@ -75,13 +75,19 @@ def update_config(file_name, yaml_contents):
     dir_path = os.path.join(__opts__['config_dir'],
                             os.path.dirname(__opts__['default_include']))
     try:
-        yaml_out = salt.utils.yaml.safe_dump(yaml_contents, default_flow_style=False)
+        yaml_out = salt.utils.yaml.safe_dump(
+            yaml_contents,
+            default_flow_style=False,
+        )
 
         if not os.path.exists(dir_path):
             log.debug('Creating directory %s', dir_path)
             os.makedirs(dir_path, 0o755)
 
         file_path = os.path.join(dir_path, file_name)
+        if not salt.utils.verify.clean_path(dir_path, file_path):
+            return 'Invalid path'
+
         with salt.utils.files.fopen(file_path, 'w') as fp_:
             fp_.write(yaml_out)
 
diff --git a/salt/wheel/file_roots.py b/salt/wheel/file_roots.py
index 02cc8c5b32..ad42335734 100644
--- a/salt/wheel/file_roots.py
+++ b/salt/wheel/file_roots.py
@@ -25,6 +25,8 @@ def find(path, saltenv='base'):
         return ret
     for root in __opts__['file_roots'][saltenv]:
         full = os.path.join(root, path)
+        if not salt.utils.verify.clean_path(root, full):
+            continue
         if os.path.isfile(full):
             # Add it to the dict
             with salt.utils.files.fopen(full, 'rb') as fp_:
@@ -107,7 +109,10 @@ def write(data, path, saltenv='base', index=0):
     if os.path.isabs(path):
         return ('The path passed in {0} is not relative to the environment '
                 '{1}').format(path, saltenv)
-    dest = os.path.join(__opts__['file_roots'][saltenv][index], path)
+    root = __opts__['file_roots'][saltenv][index]
+    dest = os.path.join(root, path)
+    if not salt.utils.verify.clean_path(root, dest, subdir=True):
+        return 'Invalid path: {}'.format(path)
     dest_dir = os.path.dirname(dest)
     if not os.path.isdir(dest_dir):
         os.makedirs(dest_dir)
diff --git a/tests/integration/master/test_clear_funcs.py b/tests/integration/master/test_clear_funcs.py
new file mode 100644
index 0000000000..4abb257dd9
--- /dev/null
+++ b/tests/integration/master/test_clear_funcs.py
@@ -0,0 +1,310 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+import getpass
+import os
+import tempfile
+import time
+
+import salt.master
+import salt.transport.client
+import salt.utils.platform
+import salt.utils.files
+import salt.utils.user
+
+from tests.support.case import TestCase
+from tests.support.mixins import AdaptedConfigurationTestCaseMixin
+from tests.support.runtests import RUNTIME_VARS
+
+
+def keyuser():
+    user = salt.utils.user.get_specific_user()
+    if user.startswith('sudo_'):
+        user = user[5:].replace('\\', '_')
+    return user
+
+
+class ClearFuncsAuthTestCase(TestCase):
+
+    def test_auth_info_not_allowed(self):
+        assert hasattr(salt.master.ClearFuncs, '_prep_auth_info')
+        master = '127.0.0.1'
+        ret_port = 64506
+        user = getpass.getuser()
+        keyfile = '.{}_key'.format(user)
+
+        keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile)
+
+        with salt.utils.files.fopen(keypath) as keyfd:
+            key = keyfd.read()
+
+        minion_config = {
+            'transport': 'zeromq',
+            'pki_dir': '/tmp',
+            'id': 'root',
+            'master_ip': master,
+            'master_port': ret_port,
+            'auth_timeout': 5,
+            'auth_tries': 1,
+            'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+        }
+
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            minion_config, crypt='clear')
+
+        msg = {'cmd': '_prep_auth_info'}
+        rets = clear_channel.send(msg, timeout=15)
+        ret_key = None
+        for ret in rets:
+            try:
+                ret_key = ret[user]
+                break
+            except (TypeError, KeyError):
+                pass
+        assert ret_key != key, 'Able to retrieve user key'
+
+
+class ClearFuncsPubTestCase(TestCase):
+
+    def setUp(self):
+        self.master = '127.0.0.1'
+        self.ret_port = 64506
+        self.tmpfile = os.path.join(tempfile.mkdtemp(), 'evil_file')
+        self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+
+    def tearDown(self):
+        try:
+            os.remove(self.tmpfile + 'x')
+        except OSError:
+            pass
+        delattr(self, 'master')
+        delattr(self, 'ret_port')
+        delattr(self, 'tmpfile')
+
+    def test_pub_not_allowed(self):
+        assert hasattr(salt.master.ClearFuncs, '_send_pub')
+        assert not os.path.exists(self.tmpfile)
+        minion_config = {
+            'transport': 'zeromq',
+            'pki_dir': '/tmp',
+            'id': 'root',
+            'master_ip': self.master,
+            'master_port': self.ret_port,
+            'auth_timeout': 5,
+            'auth_tries': 1,
+            'master_uri': 'tcp://{0}:{1}'.format(self.master, self.ret_port),
+        }
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            minion_config, crypt='clear')
+        jid = '202003100000000001'
+        msg = {
+            'cmd': '_send_pub',
+            'fun': 'file.write',
+            'jid': jid,
+            'arg': [self.tmpfile, 'evil contents'],
+            'kwargs': {'show_jid': False, 'show_timeout': False},
+            'ret': '',
+            'tgt': 'minion',
+            'tgt_type': 'glob',
+            'user': 'root'
+        }
+        eventbus = salt.utils.event.get_event(
+            'master',
+            sock_dir=self.master_opts['sock_dir'],
+            transport=self.master_opts['transport'],
+            opts=self.master_opts)
+        ret = clear_channel.send(msg, timeout=15)
+        if salt.utils.platform.is_windows():
+            time.sleep(30)
+            timeout = 30
+        else:
+            timeout = 5
+        ret_evt = None
+        start = time.time()
+        while time.time() - start <= timeout:
+            raw = eventbus.get_event(timeout, auto_reconnect=True)
+            if raw and 'jid' in raw and raw['jid'] == jid:
+                ret_evt = raw
+                break
+        assert not os.path.exists(self.tmpfile), 'Evil file created'
+
+
+class ClearFuncsConfigTest(TestCase):
+
+    def setUp(self):
+        master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+        self.conf_dir = os.path.dirname(master_opts['conf_file'])
+        master = '127.0.0.1'
+        ret_port = 64506
+        user = keyuser()
+        keyfile = '.{}_key'.format(user)
+        keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile)
+
+        with salt.utils.files.fopen(keypath) as keyfd:
+            self.key = keyfd.read()
+
+        self.minion_config = {
+            'transport': 'zeromq',
+            'pki_dir': '/tmp',
+            'id': 'root',
+            'master_ip': master,
+            'master_port': ret_port,
+            'auth_timeout': 5,
+            'auth_tries': 1,
+            'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+        }
+
+    def tearDown(self):
+        try:
+            os.remove(os.path.join(self.conf_dir, 'evil.conf'))
+        except OSError:
+            pass
+        delattr(self, 'conf_dir')
+        delattr(self, 'key')
+        delattr(self, 'minion_config')
+
+    def test_clearfuncs_config(self):
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            self.minion_config, crypt='clear')
+
+        msg = {
+           'key': self.key,
+           'cmd': 'wheel',
+           'fun': 'config.update_config',
+           'file_name': '../evil',
+           'yaml_contents': 'win',
+        }
+        ret = clear_channel.send(msg, timeout=5)
+        assert not os.path.exists(os.path.join(self.conf_dir, 'evil.conf')), \
+            'Wrote file via directory traversal'
+
+
+class ClearFuncsFileRoots(TestCase):
+
+    def setUp(self):
+        self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+        self.target_dir = os.path.dirname(
+            self.master_opts['file_roots']['base'][0]
+        )
+        master = '127.0.0.1'
+        ret_port = 64506
+        user = keyuser()
+        self.keyfile = '.{}_key'.format(user)
+        keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', self.keyfile)
+
+        with salt.utils.files.fopen(keypath) as keyfd:
+            self.key = keyfd.read()
+
+        self.minion_config = {
+            'transport': 'zeromq',
+            'pki_dir': '/tmp',
+            'id': 'root',
+            'master_ip': master,
+            'master_port': ret_port,
+            'auth_timeout': 5,
+            'auth_tries': 1,
+            'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+        }
+
+    def tearDown(self):
+        try:
+            os.remove(os.path.join(self.target_dir, 'pwn.txt'))
+        except OSError:
+            pass
+        delattr(self, 'master_opts')
+        delattr(self, 'target_dir')
+        delattr(self, 'keyfile')
+        delattr(self, 'key')
+        delattr(self, 'minion_config')
+
+    def test_fileroots_write(self):
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            self.minion_config, crypt='clear')
+
+        msg = {
+            'key': self.key,
+            'cmd': 'wheel',
+            'fun': 'file_roots.write',
+            'data': 'win',
+            'path': os.path.join('..', 'pwn.txt'),
+            'saltenv': 'base',
+        }
+        ret = clear_channel.send(msg, timeout=5)
+        assert not os.path.exists(os.path.join(self.target_dir, 'pwn.txt')), \
+            'Wrote file via directory traversal'
+
+    def test_fileroots_read(self):
+        rootdir = self.master_opts['root_dir']
+        fileroot = self.master_opts['file_roots']['base'][0]
+        # If this asserion fails the test may need to be re-written
+        assert os.path.dirname(os.path.dirname(rootdir)) == os.path.dirname(fileroot)
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            self.minion_config, crypt='clear')
+        readpath = os.path.join(
+            '..',
+            'salt-tests-tmpdir',
+            'rootdir',
+            'cache',
+            self.keyfile,
+        )
+        msg = {
+            'key': self.key,
+            'cmd': 'wheel',
+            'fun': 'file_roots.read',
+            'path': os.path.join(
+                '..',
+                'salt-tests-tmpdir',
+                'rootdir',
+                'cache',
+                self.keyfile,
+            ),
+            'saltenv': 'base',
+        }
+
+        ret = clear_channel.send(msg, timeout=5)
+        try:
+            # When vulnerable this assertion will fail.
+            assert list(ret['data']['return'][0].items())[0][1] != self.key, \
+                'Read file via directory traversal'
+        except IndexError:
+            pass
+        # If the vulnerability is fixed, no data will be returned.
+        assert ret['data']['return'] == []
+
+
+class ClearFuncsTokenTest(TestCase):
+
+    def setUp(self):
+        self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+        master = '127.0.0.1'
+        ret_port = 64506
+        self.minion_config = {
+            'transport': 'zeromq',
+            'pki_dir': '/tmp',
+            'id': 'root',
+            'master_ip': master,
+            'master_port': ret_port,
+            'auth_timeout': 5,
+            'auth_tries': 1,
+            'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+        }
+
+    def tearDown(self):
+        delattr(self, 'master_opts')
+        delattr(self, 'minion_config')
+
+    def test_token(self):
+        tokensdir = os.path.join(
+            self.master_opts['root_dir'],
+            self.master_opts['cachedir'],
+            'tokens'
+        )
+        assert os.path.exists(tokensdir), tokensdir
+        clear_channel = salt.transport.client.ReqChannel.factory(
+            self.minion_config, crypt='clear')
+        msg = {
+            'arg': [],
+            'cmd': 'get_token',
+            'token': os.path.join('..', 'minions', 'minion', 'data.p'),
+        }
+        ret = clear_channel.send(msg, timeout=5)
+        assert 'pillar' not in ret, 'Read minion data via directory traversal'
diff --git a/tests/unit/test_master.py b/tests/unit/test_master.py
index b7394ffa01..c730f61594 100644
--- a/tests/unit/test_master.py
+++ b/tests/unit/test_master.py
@@ -15,6 +15,24 @@ from tests.support.mock import (
 )
 
 
+class TransportMethodsTest(TestCase):
+
+    def test_transport_methods(self):
+
+        class Foo(salt.master.TransportMethods):
+            expose_methods = ['bar']
+
+            def bar(self):
+                pass
+
+            def bang(self):
+                pass
+
+        foo = Foo()
+        assert foo.get_method('bar') is not None
+        assert foo.get_method('bang') is None
+
+
 class ClearFuncsTestCase(TestCase):
     '''
     TestCase for salt.master.ClearFuncs class
@@ -24,6 +42,13 @@ class ClearFuncsTestCase(TestCase):
         opts = salt.config.master_config(None)
         self.clear_funcs = salt.master.ClearFuncs(opts, {})
 
+    def tearDown(self):
+        del self.clear_funcs
+
+    def test_get_method(self):
+        assert getattr(self.clear_funcs, '_send_pub', None) is not None
+        assert self.clear_funcs.get_method('_send_pub') is None
+
     # runner tests
 
     def test_runner_token_not_authenticated(self):
diff --git a/tests/unit/test_module_names.py b/tests/unit/test_module_names.py
index c4109c0916..698fa2032d 100644
--- a/tests/unit/test_module_names.py
+++ b/tests/unit/test_module_names.py
@@ -131,6 +131,7 @@ class BadTestModuleNamesTestCase(TestCase):
             'integration.loader.test_ext_grains',
             'integration.loader.test_ext_modules',
             'integration.logging.test_jid_logging',
+            'integration.master.test_clear_funcs',
             'integration.minion.test_blackout',
             'integration.minion.test_pillar',
             'integration.minion.test_timeout',
diff --git a/tests/unit/utils/test_verify.py b/tests/unit/utils/test_verify.py
index fb801da217..b263a44afa 100644
--- a/tests/unit/utils/test_verify.py
+++ b/tests/unit/utils/test_verify.py
@@ -12,6 +12,7 @@ import stat
 import shutil
 import tempfile
 import socket
+import ctypes
 
 # Import third party libs
 if sys.platform.startswith('win'):
@@ -44,6 +45,7 @@ from salt.utils.verify import (
     valid_id,
     log,
     verify_log,
+    clean_path
 )
 
 # Import 3rd-party libs
@@ -284,3 +286,81 @@ class TestVerify(TestCase):
         with patch.object(log, 'warning', mock_info):
             verify_log({'log_level': 'info'})
             self.assertTrue(mock_info.call_count == 0)
+
+
+class TestCleanPath(TestCase):
+    '''
+    salt.utils.clean_path works as expected
+    '''
+
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp()
+
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+
+    def test_clean_path_valid(self):
+        path_a = os.path.join(self.tmpdir, 'foo')
+        path_b = os.path.join(self.tmpdir, 'foo', 'bar')
+        assert clean_path(path_a, path_b) == path_b
+
+    def test_clean_path_invalid(self):
+        path_a = os.path.join(self.tmpdir, 'foo')
+        path_b = os.path.join(self.tmpdir, 'baz', 'bar')
+        assert clean_path(path_a, path_b) == ''
+
+
+__CSL = None
+
+
+def symlink(source, link_name):
+    '''
+    symlink(source, link_name) Creates a symbolic link pointing to source named
+    link_name
+    '''
+    global __CSL
+    if __CSL is None:
+        csl = ctypes.windll.kernel32.CreateSymbolicLinkW
+        csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+        csl.restype = ctypes.c_ubyte
+        __CSL = csl
+    flags = 0
+    if source is not None and os.path.isdir(source):
+        flags = 1
+    if __CSL(link_name, source, flags) == 0:
+        raise ctypes.WinError()
+
+
+@skipIf(six.PY2 and salt.utils.platform.is_windows(), 'Skipped on windows py2')
+class TestCleanPathLink(TestCase):
+    '''
+    Ensure salt.utils.clean_path works with symlinked directories and files
+    '''
+
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp()
+        self.to_path = os.path.join(self.tmpdir, 'linkto')
+        self.from_path = os.path.join(self.tmpdir, 'linkfrom')
+        if six.PY2 or salt.utils.platform.is_windows():
+            kwargs = {}
+        else:
+            kwargs = {'target_is_directory': True}
+        if salt.utils.platform.is_windows():
+            symlink(self.to_path, self.from_path, **kwargs)
+        else:
+            os.symlink(self.to_path, self.from_path, **kwargs)
+
+    def tearDown(self):
+        shutil.rmtree(self.tmpdir)
+
+    def test_clean_path_symlinked_src(self):
+        test_path = os.path.join(self.from_path, 'test')
+        expect_path = os.path.join(self.to_path, 'test')
+        ret = clean_path(self.from_path, test_path)
+        assert ret == expect_path, "{} is not {}".format(ret, expect_path)
+
+    def test_clean_path_symlinked_tgt(self):
+        test_path = os.path.join(self.to_path, 'test')
+        expect_path = os.path.join(self.to_path, 'test')
+        ret = clean_path(self.from_path, test_path)
+        assert ret == expect_path, "{} is not {}".format(ret, expect_path)
diff --git a/tests/whitelist.txt b/tests/whitelist.txt
index 9e63fa5e0a..30fddb0e24 100644
--- a/tests/whitelist.txt
+++ b/tests/whitelist.txt
@@ -10,6 +10,7 @@ integration.grains.test_core
 integration.grains.test_custom
 integration.loader.test_ext_grains
 integration.loader.test_ext_modules
+integration.master.test_clear_funcs
 integration.minion.test_blackout
 integration.minion.test_pillar
 integration.minion.test_timeout
-- 
2.16.4

