Add support for Linux capabilities
This change adds a new `capabilities` kwarg to PrivContext, which specifies the Linux capabilities to retain on the privileged side of this context. This allows the privileged daemon to be run as root but with restricted permissions, or as not-root but still with some limited superpowers. A new `capabilities` config option is added to the context config section that overrides the default capabilities for that context. It is expected that this will rarely be used. Note that there is intentionally no way to specify "I want all capabilities". Change-Id: I61169d1d27609deb04115f4119654fd3d0690357
This commit is contained in:
parent
55cb0695bd
commit
5a00350935
145
oslo_privsep/capabilities.py
Normal file
145
oslo_privsep/capabilities.py
Normal file
@ -0,0 +1,145 @@
|
||||
# Copyright 2015 Rackspace Hosting
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import os
|
||||
|
||||
import cffi
|
||||
|
||||
# Expand as necessary
|
||||
CAP_CHOWN = 0
|
||||
CAP_DAC_OVERRIDE = 1
|
||||
CAP_FOWNER = 3
|
||||
CAP_KILL = 5
|
||||
CAP_SETPCAP = 8
|
||||
CAP_NET_BIND_SERVICE = 10
|
||||
CAP_NET_BROADCAST = 11
|
||||
CAP_NET_ADMIN = 12
|
||||
CAP_NET_RAW = 13
|
||||
CAP_SYS_ADMIN = 21
|
||||
|
||||
# Convenience dicts for human readable values
|
||||
CAPS_BYNAME = {}
|
||||
CAPS_BYVALUE = {}
|
||||
for k, v in globals().copy().items():
|
||||
if k.startswith('CAP_'):
|
||||
CAPS_BYNAME[k] = v
|
||||
CAPS_BYVALUE[v] = k
|
||||
|
||||
CDEF = '''
|
||||
/* Edited highlights from `echo '#include <sys/capability.h>' | gcc -E -` */
|
||||
|
||||
#define _LINUX_CAPABILITY_VERSION_2 0x20071026
|
||||
#define _LINUX_CAPABILITY_U32S_2 2
|
||||
|
||||
typedef unsigned int __u32;
|
||||
|
||||
typedef struct __user_cap_header_struct {
|
||||
__u32 version;
|
||||
int pid;
|
||||
} *cap_user_header_t;
|
||||
|
||||
typedef struct __user_cap_data_struct {
|
||||
__u32 effective;
|
||||
__u32 permitted;
|
||||
__u32 inheritable;
|
||||
} *cap_user_data_t;
|
||||
|
||||
int capset(cap_user_header_t header, const cap_user_data_t data);
|
||||
int capget(cap_user_header_t header, cap_user_data_t data);
|
||||
|
||||
|
||||
/* Edited highlights from `echo '#include <sys/prctl.h>' | gcc -E -` */
|
||||
|
||||
#define PR_GET_KEEPCAPS 7
|
||||
#define PR_SET_KEEPCAPS 8
|
||||
|
||||
int prctl (int __option, ...);
|
||||
'''
|
||||
|
||||
ffi = cffi.FFI()
|
||||
crt = ffi.dlopen(None)
|
||||
ffi.cdef(CDEF)
|
||||
|
||||
|
||||
# mock.patching crt.* directly seems to upset cffi. Use an
|
||||
# indirection point here for easier testing.
|
||||
_prctl = crt.prctl
|
||||
_capget = crt.capget
|
||||
_capset = crt.capset
|
||||
|
||||
|
||||
def set_keepcaps(enable):
|
||||
"""Set/unset thread's "keep capabilities" flag - see prctl(2)"""
|
||||
ret = _prctl(crt.PR_SET_KEEPCAPS,
|
||||
ffi.cast('unsigned long', bool(enable)))
|
||||
if ret != 0:
|
||||
errno = ffi.errno
|
||||
raise OSError(errno, os.strerror(errno))
|
||||
|
||||
|
||||
def drop_all_caps_except(effective, permitted, inheritable):
|
||||
"""Set (effective, permitted, inheritable) to provided list of caps"""
|
||||
eff = _caps_to_mask(effective)
|
||||
prm = _caps_to_mask(permitted)
|
||||
inh = _caps_to_mask(inheritable)
|
||||
|
||||
header = ffi.new('cap_user_header_t',
|
||||
{'version': crt._LINUX_CAPABILITY_VERSION_2,
|
||||
'pid': 0})
|
||||
data = ffi.new('struct __user_cap_data_struct[2]')
|
||||
data[0].effective = eff & 0xffffffff
|
||||
data[1].effective = eff >> 32
|
||||
data[0].permitted = prm & 0xffffffff
|
||||
data[1].permitted = prm >> 32
|
||||
data[0].inheritable = inh & 0xffffffff
|
||||
data[1].inheritable = inh >> 32
|
||||
|
||||
ret = _capset(header, data)
|
||||
if ret != 0:
|
||||
errno = ffi.errno
|
||||
raise OSError(errno, os.strerror(errno))
|
||||
|
||||
|
||||
def _mask_to_caps(mask):
|
||||
"""Convert bitmask to list of set bit offsets"""
|
||||
return [i for i in range(64) if (1 << i) & mask]
|
||||
|
||||
|
||||
def _caps_to_mask(caps):
|
||||
"""Convert list of bit offsets to bitmask"""
|
||||
mask = 0
|
||||
for cap in caps:
|
||||
mask |= 1 << cap
|
||||
return mask
|
||||
|
||||
|
||||
def get_caps():
|
||||
"""Return (effective, permitted, inheritable) as lists of caps"""
|
||||
header = ffi.new('cap_user_header_t',
|
||||
{'version': crt._LINUX_CAPABILITY_VERSION_2,
|
||||
'pid': 0})
|
||||
data = ffi.new('struct __user_cap_data_struct[2]')
|
||||
ret = _capget(header, data)
|
||||
if ret != 0:
|
||||
errno = ffi.errno
|
||||
raise OSError(errno, os.strerror(errno))
|
||||
|
||||
return (
|
||||
_mask_to_caps(data[0].effective |
|
||||
(data[1].effective << 32)),
|
||||
_mask_to_caps(data[0].permitted |
|
||||
(data[1].permitted << 32)),
|
||||
_mask_to_caps(data[0].inheritable |
|
||||
(data[1].inheritable << 32)),
|
||||
)
|
@ -62,6 +62,7 @@ from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import importutils
|
||||
|
||||
from oslo_privsep import capabilities
|
||||
from oslo_privsep import comm
|
||||
from oslo_privsep._i18n import _, _LE, _LI
|
||||
|
||||
@ -322,6 +323,7 @@ class Daemon(object):
|
||||
self.context = context
|
||||
self.user = context.conf.user
|
||||
self.group = context.conf.group
|
||||
self.caps = set(context.conf.capabilities)
|
||||
|
||||
def run(self):
|
||||
"""Run request loop. Sets up environment, then calls loop()"""
|
||||
@ -339,19 +341,48 @@ class Daemon(object):
|
||||
# stderr is left untouched
|
||||
|
||||
def _drop_privs(self):
|
||||
if self.group is not None:
|
||||
try:
|
||||
os.setgroups([])
|
||||
except OSError:
|
||||
msg = _('Failed to remove supplemental groups')
|
||||
LOG.critical(msg)
|
||||
raise FailedToDropPrivileges(msg)
|
||||
if self.user is not None:
|
||||
setuid(self.user)
|
||||
try:
|
||||
# Keep current capabilities across setuid away from root.
|
||||
capabilities.set_keepcaps(True)
|
||||
|
||||
if self.group is not None:
|
||||
try:
|
||||
os.setgroups([])
|
||||
except OSError:
|
||||
msg = _('Failed to remove supplemental groups')
|
||||
LOG.critical(msg)
|
||||
raise FailedToDropPrivileges(msg)
|
||||
|
||||
if self.user is not None:
|
||||
setuid(self.user)
|
||||
|
||||
if self.group is not None:
|
||||
setgid(self.group)
|
||||
|
||||
finally:
|
||||
capabilities.set_keepcaps(False)
|
||||
|
||||
LOG.info(_LI('privsep process running with uid/gid: %(uid)s/%(gid)s'),
|
||||
{'uid': os.getuid(), 'gid': os.getgid()})
|
||||
|
||||
capabilities.drop_all_caps_except(self.caps, self.caps, [])
|
||||
|
||||
def fmt_caps(capset):
|
||||
if not capset:
|
||||
return 'none'
|
||||
return '|'.join(sorted(capabilities.CAPS_BYVALUE[c]
|
||||
for c in capset))
|
||||
|
||||
eff, prm, inh = capabilities.get_caps()
|
||||
LOG.info(
|
||||
_LI('privsep process running with capabilities '
|
||||
'(eff/prm/inh): %(eff)s/%(prm)s/%(inh)s'),
|
||||
{
|
||||
'eff': fmt_caps(eff),
|
||||
'prm': fmt_caps(prm),
|
||||
'inh': fmt_caps(inh),
|
||||
})
|
||||
|
||||
def _process_cmd(self, cmd, *args):
|
||||
if cmd == Message.PING:
|
||||
return (Message.PONG.value,)
|
||||
|
@ -17,19 +17,34 @@ import enum
|
||||
import functools
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_config import types
|
||||
from oslo_log import log as logging
|
||||
|
||||
from oslo_privsep import capabilities
|
||||
from oslo_privsep import daemon
|
||||
from oslo_privsep._i18n import _, _LW
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def CapNameOrInt(value):
|
||||
value = str(value).strip()
|
||||
try:
|
||||
return capabilities.CAPS_BYNAME[value]
|
||||
except KeyError:
|
||||
return int(value)
|
||||
|
||||
|
||||
OPTS = [
|
||||
cfg.StrOpt('user',
|
||||
help=_('User that the privsep daemon should run as.')),
|
||||
cfg.StrOpt('group',
|
||||
help=_('Group that the privsep daemon should run as.')),
|
||||
cfg.Opt('capabilities',
|
||||
type=types.List(CapNameOrInt), default=[],
|
||||
help=_('List of Linux capabilities retained by the privsep '
|
||||
'daemon.')),
|
||||
cfg.StrOpt('helper_command',
|
||||
default=('sudo privsep-helper'
|
||||
# TODO(gus): how do I find a good config path?
|
||||
@ -48,7 +63,19 @@ class Method(enum.Enum):
|
||||
|
||||
|
||||
class PrivContext(object):
|
||||
def __init__(self, prefix, cfg_section='privsep', pypath=None):
|
||||
def __init__(self, prefix, cfg_section='privsep', pypath=None,
|
||||
capabilities=None):
|
||||
|
||||
# Note that capabilities=[] means retaining no capabilities
|
||||
# and leaves even uid=0 with no powers except being able to
|
||||
# read/write to the filesystem as uid=0. This might be what
|
||||
# you want, but probably isn't.
|
||||
#
|
||||
# There is intentionally no way to say "I want all the
|
||||
# capabilities."
|
||||
if capabilities is None:
|
||||
raise ValueError('capabilities is a required parameter')
|
||||
|
||||
self.pypath = pypath
|
||||
self.prefix = prefix
|
||||
self.cfg_section = cfg_section
|
||||
@ -56,6 +83,8 @@ class PrivContext(object):
|
||||
self.channel = None
|
||||
|
||||
cfg.CONF.register_opts(OPTS, group=cfg_section)
|
||||
cfg.CONF.set_default('capabilities', group=cfg_section,
|
||||
default=capabilities)
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
|
88
oslo_privsep/tests/test_capabilities.py
Normal file
88
oslo_privsep/tests/test_capabilities.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright 2015 Rackspace Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import mock
|
||||
|
||||
from oslotest import base
|
||||
|
||||
from oslo_privsep import capabilities
|
||||
|
||||
|
||||
class TestCapabilities(base.BaseTestCase):
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._prctl')
|
||||
def test_set_keepcaps_error(self, mock_prctl):
|
||||
mock_prctl.return_value = -1
|
||||
self.assertRaises(OSError, capabilities.set_keepcaps, True)
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._prctl')
|
||||
def test_set_keepcaps(self, mock_prctl):
|
||||
mock_prctl.return_value = 0
|
||||
capabilities.set_keepcaps(True)
|
||||
|
||||
# Disappointingly, ffi.cast(type, 1) != ffi.cast(type, 1)
|
||||
# so can't just use assert_called_once_with :-(
|
||||
self.assertEqual(1, mock_prctl.call_count)
|
||||
self.assertItemsEqual(
|
||||
[8, 1], # [PR_SET_KEEPCAPS, true]
|
||||
[int(x) for x in mock_prctl.call_args[0]])
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._capset')
|
||||
def test_drop_all_caps_except_error(self, mock_capset):
|
||||
mock_capset.return_value = -1
|
||||
self.assertRaises(
|
||||
OSError, capabilities.drop_all_caps_except, [0], [0], [0])
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._capset')
|
||||
def test_drop_all_caps_except(self, mock_capset):
|
||||
mock_capset.return_value = 0
|
||||
|
||||
# Somewhat arbitrary bit patterns to exercise _caps_to_mask
|
||||
capabilities.drop_all_caps_except(
|
||||
(17, 24, 49), (8, 10, 35, 56), (24, 31, 40))
|
||||
|
||||
self.assertEqual(1, mock_capset.call_count)
|
||||
hdr, data = mock_capset.call_args[0]
|
||||
self.assertEqual(0x20071026, # _LINUX_CAPABILITY_VERSION_2
|
||||
hdr.version)
|
||||
self.assertEqual(0x01020000, data[0].effective)
|
||||
self.assertEqual(0x00020000, data[1].effective)
|
||||
self.assertEqual(0x00000500, data[0].permitted)
|
||||
self.assertEqual(0x01000008, data[1].permitted)
|
||||
self.assertEqual(0x81000000, data[0].inheritable)
|
||||
self.assertEqual(0x00000100, data[1].inheritable)
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._capget')
|
||||
def test_get_caps_error(self, mock_capget):
|
||||
mock_capget.return_value = -1
|
||||
self.assertRaises(OSError, capabilities.get_caps)
|
||||
|
||||
@mock.patch('oslo_privsep.capabilities._capget')
|
||||
def test_get_caps(self, mock_capget):
|
||||
def impl(hdr, data):
|
||||
# Somewhat arbitrary bit patterns to exercise _mask_to_caps
|
||||
data[0].effective = 0x01020000
|
||||
data[1].effective = 0x00020000
|
||||
data[0].permitted = 0x00000500
|
||||
data[1].permitted = 0x01000008
|
||||
data[0].inheritable = 0x81000000
|
||||
data[1].inheritable = 0x00000100
|
||||
return 0
|
||||
mock_capget.side_effect = impl
|
||||
|
||||
self.assertItemsEqual(
|
||||
([17, 24, 49],
|
||||
[8, 10, 35, 56],
|
||||
[24, 31, 40]),
|
||||
capabilities.get_caps())
|
@ -13,10 +13,14 @@
|
||||
# under the License.
|
||||
|
||||
import fixtures
|
||||
import mock
|
||||
import time
|
||||
|
||||
from oslo_log import log as logging
|
||||
from oslotest import base
|
||||
|
||||
from oslo_privsep import capabilities
|
||||
from oslo_privsep import daemon
|
||||
from oslo_privsep.tests import testctx
|
||||
|
||||
|
||||
@ -53,7 +57,40 @@ class LogTest(testctx.TestContextTestCase):
|
||||
self.assertIn('test@WARN', self.logger.output)
|
||||
|
||||
|
||||
class TestDaemon(testctx.TestContextTestCase):
|
||||
class TestDaemon(base.BaseTestCase):
|
||||
|
||||
@mock.patch('os.setuid')
|
||||
@mock.patch('os.setgid')
|
||||
@mock.patch('os.setgroups')
|
||||
@mock.patch('oslo_privsep.capabilities.set_keepcaps')
|
||||
@mock.patch('oslo_privsep.capabilities.drop_all_caps_except')
|
||||
def test_drop_privs(self, mock_dropcaps, mock_keepcaps,
|
||||
mock_setgroups, mock_setgid, mock_setuid):
|
||||
channel = mock.NonCallableMock()
|
||||
context = mock.NonCallableMock()
|
||||
context.conf.user = 42
|
||||
context.conf.group = 84
|
||||
context.conf.capabilities = [
|
||||
capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN]
|
||||
|
||||
d = daemon.Daemon(channel, context)
|
||||
d._drop_privs()
|
||||
|
||||
mock_setuid.assert_called_once_with(42)
|
||||
mock_setgid.assert_called_once_with(84)
|
||||
mock_setgroups.assert_called_once_with([])
|
||||
|
||||
self.assertItemsEqual(
|
||||
[mock.call(True), mock.call(False)],
|
||||
mock_keepcaps.mock_calls)
|
||||
|
||||
mock_dropcaps.assert_called_once_with(
|
||||
set((capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN)),
|
||||
set((capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN)),
|
||||
[])
|
||||
|
||||
|
||||
class TestWithContext(testctx.TestContextTestCase):
|
||||
|
||||
def test_unexported(self):
|
||||
self.assertRaisesRegexp(
|
||||
|
@ -25,6 +25,8 @@ context = priv_context.PrivContext(
|
||||
# This context allows entrypoints anywhere below oslo_privsep.tests.
|
||||
oslo_privsep.tests.__name__,
|
||||
pypath=__name__ + '.context',
|
||||
# This is one of the rare cases where we actually want zero powers:
|
||||
capabilities=[],
|
||||
)
|
||||
|
||||
|
||||
|
@ -8,3 +8,4 @@ oslo.i18n>=1.5.0 # Apache-2.0
|
||||
oslo.config>=2.6.0 # Apache-2.0
|
||||
oslo.utils>=2.4.0,!=2.6.0 # Apache-2.0
|
||||
enum34;python_version=='2.7' or python_version=='2.6'
|
||||
cffi
|
||||
|
Loading…
x
Reference in New Issue
Block a user