From 12e264d58f052f192f3408f5cd8637809eff085b Mon Sep 17 00:00:00 2001 From: Thierry Carrez Date: Fri, 16 Nov 2012 15:50:01 +0100 Subject: [PATCH] Configurable exec_dirs to find rootwrap commands Adds support for a configurable set of trusted directories to search executables in (exec_dirs), which defaults to system PATH. If your filter specifies an exec_path that doesn't start with '/', then it will be searched in exec_dirs. Avoids having to write multiple filters to care for distro differences. Fixes bug 1079723. Also returns a specific error rather than try to run absent executables. Change-Id: Idab03bb0be6832a75ffeed4e78d25d0543f5caf9 --- bin/nova-rootwrap | 38 ++++++++++++++++-------- etc/nova/rootwrap.conf | 6 ++++ etc/nova/rootwrap.d/api-metadata.filters | 12 +++----- etc/nova/rootwrap.d/compute.filters | 30 +++++++------------ etc/nova/rootwrap.d/network.filters | 18 ++++------- nova/rootwrap/filters.py | 29 ++++++++++++++---- nova/rootwrap/wrapper.py | 38 ++++++++++++++++++------ nova/tests/test_nova_rootwrap.py | 20 +++++++++---- 8 files changed, 119 insertions(+), 72 deletions(-) diff --git a/bin/nova-rootwrap b/bin/nova-rootwrap index a28205a80357..3322bc815bd1 100755 --- a/bin/nova-rootwrap +++ b/bin/nova-rootwrap @@ -42,6 +42,7 @@ import sys RC_UNAUTHORIZED = 99 RC_NOCOMMAND = 98 RC_BADCONFIG = 97 +RC_NOEXECFOUND = 96 def _subprocess_setup(): @@ -65,6 +66,11 @@ if __name__ == '__main__': config.read(configfile) try: filters_path = config.get("DEFAULT", "filters_path").split(",") + if config.has_option("DEFAULT", "exec_dirs"): + exec_dirs = config.get("DEFAULT", "exec_dirs").split(",") + else: + # Use system PATH if exec_dirs is not specified + exec_dirs = os.environ["PATH"].split(':') except ConfigParser.Error: print "%s: Incorrect configuration file: %s" % (execname, configfile) sys.exit(RC_BADCONFIG) @@ -79,16 +85,24 @@ if __name__ == '__main__': # Execute command if it matches any of the loaded filters filters = wrapper.load_filters(filters_path) - filtermatch = wrapper.match_filter(filters, userargs) - if filtermatch: - obj = subprocess.Popen(filtermatch.get_command(userargs), - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - preexec_fn=_subprocess_setup, - env=filtermatch.get_environment(userargs)) - obj.wait() - sys.exit(obj.returncode) + try: + filtermatch = wrapper.match_filter(filters, userargs, + exec_dirs=exec_dirs) + if filtermatch: + obj = subprocess.Popen(filtermatch.get_command(userargs, + exec_dirs=exec_dirs), + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + preexec_fn=_subprocess_setup, + env=filtermatch.get_environment(userargs)) + obj.wait() + sys.exit(obj.returncode) - print "Unauthorized command: %s" % ' '.join(userargs) - sys.exit(RC_UNAUTHORIZED) + except wrapper.FilterMatchNotExecutable as exc: + print "Executable not found: %s" % exc.match.exec_path + sys.exit(RC_NOEXECFOUND) + + except wrapper.NoFilterMatched: + print "Unauthorized command: %s" % ' '.join(userargs) + sys.exit(RC_UNAUTHORIZED) diff --git a/etc/nova/rootwrap.conf b/etc/nova/rootwrap.conf index 730f71695e6d..5d6034eb9478 100644 --- a/etc/nova/rootwrap.conf +++ b/etc/nova/rootwrap.conf @@ -5,3 +5,9 @@ # List of directories to load filter definitions from (separated by ','). # These directories MUST all be only writeable by root ! filters_path=/etc/nova/rootwrap.d,/usr/share/nova/rootwrap + +# List of directories to search executables in, in case filters do not +# explicitely specify a full path (separated by ',') +# If not specified, defaults to system PATH environment variable. +# These directories MUST all be only writeable by root ! +exec_dirs=/sbin,/usr/sbin,/bin,/usr/bin diff --git a/etc/nova/rootwrap.d/api-metadata.filters b/etc/nova/rootwrap.d/api-metadata.filters index ef454cbff2b7..1aa6f83e68df 100644 --- a/etc/nova/rootwrap.d/api-metadata.filters +++ b/etc/nova/rootwrap.d/api-metadata.filters @@ -5,13 +5,9 @@ [Filters] # nova/network/linux_net.py: 'ip[6]tables-save' % (cmd, '-t', ... -iptables-save: CommandFilter, /sbin/iptables-save, root -iptables-save_usr: CommandFilter, /usr/sbin/iptables-save, root -ip6tables-save: CommandFilter, /sbin/ip6tables-save, root -ip6tables-save_usr: CommandFilter, /usr/sbin/ip6tables-save, root +iptables-save: CommandFilter, iptables-save, root +ip6tables-save: CommandFilter, ip6tables-save, root # nova/network/linux_net.py: 'ip[6]tables-restore' % (cmd,) -iptables-restore: CommandFilter, /sbin/iptables-restore, root -iptables-restore_usr: CommandFilter, /usr/sbin/iptables-restore, root -ip6tables-restore: CommandFilter, /sbin/ip6tables-restore, root -ip6tables-restore_usr: CommandFilter, /usr/sbin/ip6tables-restore, root +iptables-restore: CommandFilter, iptables-restore, root +ip6tables-restore: CommandFilter, ip6tables-restore, root diff --git a/etc/nova/rootwrap.d/compute.filters b/etc/nova/rootwrap.d/compute.filters index 62fbcff679e6..cb7ad7487da2 100644 --- a/etc/nova/rootwrap.d/compute.filters +++ b/etc/nova/rootwrap.d/compute.filters @@ -72,8 +72,7 @@ ip: CommandFilter, /sbin/ip, root # nova/virt/libvirt/vif.py: 'tunctl', '-b', '-t', dev # nova/network/linux_net.py: 'tunctl', '-b', '-t', dev -tunctl: CommandFilter, /bin/tunctl, root -tunctl_usr: CommandFilter, /usr/sbin/tunctl, root +tunctl: CommandFilter, tunctl, root # nova/virt/libvirt/vif.py: 'ovs-vsctl', ... # nova/virt/libvirt/vif.py: 'ovs-vsctl', 'del-port', ... @@ -87,13 +86,11 @@ ovs-ofctl: CommandFilter, /usr/bin/ovs-ofctl, root dd: CommandFilter, /bin/dd, root # nova/virt/xenapi/volume_utils.py: 'iscsiadm', '-m', ... -iscsiadm: CommandFilter, /sbin/iscsiadm, root -iscsiadm_usr: CommandFilter, /usr/bin/iscsiadm, root +iscsiadm: CommandFilter, iscsiadm, root # nova/virt/xenapi/vm_utils.py: parted, --script, ... # nova/virt/xenapi/vm_utils.py: 'parted', '--script', dev_path, ..*. -parted: CommandFilter, /sbin/parted, root -parted_usr: CommandFilter, /usr/sbin/parted, root +parted: CommandFilter, parted, root # nova/virt/xenapi/vm_utils.py: fdisk %(dev_path)s fdisk: CommandFilter, /sbin/fdisk, root @@ -105,21 +102,16 @@ e2fsck: CommandFilter, /sbin/e2fsck, root resize2fs: CommandFilter, /sbin/resize2fs, root # nova/network/linux_net.py: 'ip[6]tables-save' % (cmd, '-t', ... -iptables-save: CommandFilter, /sbin/iptables-save, root -iptables-save_usr: CommandFilter, /usr/sbin/iptables-save, root -ip6tables-save: CommandFilter, /sbin/ip6tables-save, root -ip6tables-save_usr: CommandFilter, /usr/sbin/ip6tables-save, root +iptables-save: CommandFilter, iptables-save, root +ip6tables-save: CommandFilter, ip6tables-save, root # nova/network/linux_net.py: 'ip[6]tables-restore' % (cmd,) -iptables-restore: CommandFilter, /sbin/iptables-restore, root -iptables-restore_usr: CommandFilter, /usr/sbin/iptables-restore, root -ip6tables-restore: CommandFilter, /sbin/ip6tables-restore, root -ip6tables-restore_usr: CommandFilter, /usr/sbin/ip6tables-restore, root +iptables-restore: CommandFilter, iptables-restore, root +ip6tables-restore: CommandFilter, ip6tables-restore, root # nova/network/linux_net.py: 'arping', '-U', floating_ip, '-A', '-I', ... # nova/network/linux_net.py: 'arping', '-U', network_ref['dhcp_server'],.. -arping: CommandFilter, /usr/bin/arping, root -arping_sbin: CommandFilter, /sbin/arping, root +arping: CommandFilter, arping, root # nova/network/linux_net.py: 'dhcp_release', dev, address, mac_address dhcp_release: CommandFilter, /usr/bin/dhcp_release, root @@ -142,8 +134,7 @@ radvd: CommandFilter, /usr/sbin/radvd, root # nova/network/linux_net.py: 'brctl', 'setfd', bridge, 0 # nova/network/linux_net.py: 'brctl', 'stp', bridge, 'off' # nova/network/linux_net.py: 'brctl', 'addif', bridge, interface -brctl: CommandFilter, /sbin/brctl, root -brctl_usr: CommandFilter, /usr/sbin/brctl, root +brctl: CommandFilter, brctl, root # nova/virt/libvirt/utils.py: 'mkswap' # nova/virt/xenapi/vm_utils.py: 'mkswap' @@ -156,8 +147,7 @@ mkfs: CommandFilter, /sbin/mkfs, root qemu-img: CommandFilter, /usr/bin/qemu-img, root # nova/virt/disk/vfs/localfs.py: 'readlink', '-e' -readlink: CommandFilter, /bin/readlink, root -readlink_usr: CommandFilter, /usr/bin/readlink, root +readlink: CommandFilter, readlink, root # nova/virt/disk/api.py: 'touch', target touch: CommandFilter, /usr/bin/touch, root diff --git a/etc/nova/rootwrap.d/network.filters b/etc/nova/rootwrap.d/network.filters index 1334755008dd..c58bc77e7aaa 100644 --- a/etc/nova/rootwrap.d/network.filters +++ b/etc/nova/rootwrap.d/network.filters @@ -40,21 +40,16 @@ ebtables: CommandFilter, /sbin/ebtables, root ebtables_usr: CommandFilter, /usr/sbin/ebtables, root # nova/network/linux_net.py: 'ip[6]tables-save' % (cmd, '-t', ... -iptables-save: CommandFilter, /sbin/iptables-save, root -iptables-save_usr: CommandFilter, /usr/sbin/iptables-save, root -ip6tables-save: CommandFilter, /sbin/ip6tables-save, root -ip6tables-save_usr: CommandFilter, /usr/sbin/ip6tables-save, root +iptables-save: CommandFilter, iptables-save, root +ip6tables-save: CommandFilter, ip6tables-save, root # nova/network/linux_net.py: 'ip[6]tables-restore' % (cmd,) -iptables-restore: CommandFilter, /sbin/iptables-restore, root -iptables-restore_usr: CommandFilter, /usr/sbin/iptables-restore, root -ip6tables-restore: CommandFilter, /sbin/ip6tables-restore, root -ip6tables-restore_usr: CommandFilter, /usr/sbin/ip6tables-restore, root +iptables-restore: CommandFilter, iptables-restore, root +ip6tables-restore: CommandFilter, ip6tables-restore, root # nova/network/linux_net.py: 'arping', '-U', floating_ip, '-A', '-I', ... # nova/network/linux_net.py: 'arping', '-U', network_ref['dhcp_server'],.. -arping: CommandFilter, /usr/bin/arping, root -arping_sbin: CommandFilter, /sbin/arping, root +arping: CommandFilter, arping, root # nova/network/linux_net.py: 'dhcp_release', dev, address, mac_address dhcp_release: CommandFilter, /usr/bin/dhcp_release, root @@ -77,8 +72,7 @@ radvd: CommandFilter, /usr/sbin/radvd, root # nova/network/linux_net.py: 'brctl', 'setfd', bridge, 0 # nova/network/linux_net.py: 'brctl', 'stp', bridge, 'off' # nova/network/linux_net.py: 'brctl', 'addif', bridge, interface -brctl: CommandFilter, /sbin/brctl, root -brctl_usr: CommandFilter, /usr/sbin/brctl, root +brctl: CommandFilter, brctl, root # nova/network/linux_net.py: 'sysctl', .... sysctl: CommandFilter, /sbin/sysctl, root diff --git a/nova/rootwrap/filters.py b/nova/rootwrap/filters.py index 46a812e5df88..a3e5f1c3cd6a 100644 --- a/nova/rootwrap/filters.py +++ b/nova/rootwrap/filters.py @@ -26,6 +26,23 @@ class CommandFilter(object): self.exec_path = exec_path self.run_as = run_as self.args = args + self.real_exec = None + + def get_exec(self, exec_dirs=[]): + """Returns existing executable, or empty string if none found""" + if self.real_exec is not None: + return self.real_exec + self.real_exec = "" + if self.exec_path.startswith('/'): + if os.access(self.exec_path, os.X_OK): + self.real_exec = self.exec_path + else: + for binary_path in exec_dirs: + expanded_path = os.path.join(binary_path, self.exec_path) + if os.access(expanded_path, os.X_OK): + self.real_exec = expanded_path + break + return self.real_exec def match(self, userargs): """Only check that the first argument (command) matches exec_path""" @@ -33,12 +50,13 @@ class CommandFilter(object): return True return False - def get_command(self, userargs): + def get_command(self, userargs, exec_dirs=[]): """Returns command to execute (with sudo -u if run_as != root).""" + to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path if (self.run_as != 'root'): # Used to run commands at lesser privileges - return ['sudo', '-u', self.run_as, self.exec_path] + userargs[1:] - return [self.exec_path] + userargs[1:] + return ['sudo', '-u', self.run_as, to_exec] + userargs[1:] + return [to_exec] + userargs[1:] def get_environment(self, userargs): """Returns specific environment to set, None if none""" @@ -82,9 +100,10 @@ class DnsmasqFilter(CommandFilter): return True return False - def get_command(self, userargs): + def get_command(self, userargs, exec_dirs=[]): + to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path dnsmasq_pos = userargs.index('dnsmasq') - return [self.exec_path] + userargs[dnsmasq_pos + 1:] + return [to_exec] + userargs[dnsmasq_pos + 1:] def get_environment(self, userargs): env = os.environ.copy() diff --git a/nova/rootwrap/wrapper.py b/nova/rootwrap/wrapper.py index 3dd7ee7e33b5..742f23b149cb 100644 --- a/nova/rootwrap/wrapper.py +++ b/nova/rootwrap/wrapper.py @@ -23,6 +23,20 @@ import string from nova.rootwrap import filters +class NoFilterMatched(Exception): + """This exception is raised when no filter matched.""" + pass + + +class FilterMatchNotExecutable(Exception): + """ + This exception is raised when a filter matched but no executable was + found. + """ + def __init__(self, match=None, **kwargs): + self.match = match + + def build_filter(class_name, *args): """Returns a filter object of class class_name""" if not hasattr(filters, class_name): @@ -50,23 +64,29 @@ def load_filters(filters_path): return filterlist -def match_filter(filters, userargs): +def match_filter(filters, userargs, exec_dirs=[]): """ Checks user command and arguments through command filters and - returns the first matching filter, or None is none matched. + returns the first matching filter. + Raises NoFilterMatched if no filter matched. + Raises FilterMatchNotExecutable if no executable was found for the + best filter match. """ - - found_filter = None + first_not_executable_filter = None for f in filters: if f.match(userargs): # Try other filters if executable is absent - if not os.access(f.exec_path, os.X_OK): - if not found_filter: - found_filter = f + if not f.get_exec(exec_dirs=exec_dirs): + if not first_not_executable_filter: + first_not_executable_filter = f continue # Otherwise return matching filter for execution return f - # No filter matched or first missing executable - return found_filter + if first_not_executable_filter: + # A filter matched, but no executable was found for it + raise FilterMatchNotExecutable(match=first_not_executable_filter) + + # No filter matched + raise NoFilterMatched() diff --git a/nova/tests/test_nova_rootwrap.py b/nova/tests/test_nova_rootwrap.py index 135a5e46e772..1dfd57a723a4 100644 --- a/nova/tests/test_nova_rootwrap.py +++ b/nova/tests/test_nova_rootwrap.py @@ -43,16 +43,16 @@ class RootwrapTestCase(test.TestCase): def test_RegExpFilter_reject(self): usercmd = ["ls", "root"] - filtermatch = wrapper.match_filter(self.filters, usercmd) - self.assertTrue(filtermatch is None) + self.assertRaises(wrapper.NoFilterMatched, + wrapper.match_filter, self.filters, usercmd) def test_missing_command(self): valid_but_missing = ["foo_bar_not_exist"] invalid = ["foo_bar_not_exist_and_not_matched"] - filtermatch = wrapper.match_filter(self.filters, valid_but_missing) - self.assertTrue(filtermatch is not None) - filtermatch = wrapper.match_filter(self.filters, invalid) - self.assertTrue(filtermatch is None) + self.assertRaises(wrapper.FilterMatchNotExecutable, + wrapper.match_filter, self.filters, valid_but_missing) + self.assertRaises(wrapper.NoFilterMatched, + wrapper.match_filter, self.filters, invalid) def _test_DnsmasqFilter(self, filter_class, config_file_arg): usercmd = ['env', config_file_arg + '=A', 'NETWORK_ID=foobar', @@ -136,6 +136,14 @@ class RootwrapTestCase(test.TestCase): self.assertEqual(f.get_command(usercmd), ['/bin/cat', goodfn]) self.assertTrue(f.match(usercmd)) + def test_exec_dirs_search(self): + # This test supposes you have /bin/cat or /usr/bin/cat locally + f = filters.CommandFilter("cat", "root") + usercmd = ['cat', '/f'] + self.assertTrue(f.match(usercmd)) + self.assertTrue(f.get_command(usercmd, exec_dirs=['/bin', + '/usr/bin']) in (['/bin/cat', '/f'], ['/usr/bin/cat', '/f'])) + def test_skips(self): # Check that all filters are skipped and that the last matches usercmd = ["cat", "/"]