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
This commit is contained in:
Thierry Carrez 2012-11-16 15:50:01 +01:00
parent 651637ad54
commit 12e264d58f
8 changed files with 119 additions and 72 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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", "/"]