feat(containers): enforce disable bind mounts (#4110)

* feat(containers): enforce disable bind mounts

* refactor(docker): move check for endpoint admin to a function

* feat(docker): check if service has bind mounts

* feat(services): allow bind mounts for endpoint admin

* feat(container): enable bind mounts for endpoint admin

* fix(services): fix typo
pull/4126/head
Chaim Lev-Ari 2020-07-29 12:10:46 +03:00 committed by GitHub
parent 7539f09f98
commit 93d8c179f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 132 additions and 27 deletions

View File

@ -9,8 +9,7 @@ import (
"net/http" "net/http"
"github.com/docker/docker/client" "github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils" "github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
@ -163,6 +162,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
Devices []interface{} `json:"Devices"` Devices []interface{} `json:"Devices"`
CapAdd []string `json:"CapAdd"` CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"` CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
} `json:"HostConfig"` } `json:"HostConfig"`
} }
@ -175,25 +175,12 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return nil, err return nil, err
} }
user, err := transport.dataStore.User().User(tokenData.ID) isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rbacExtension, err := transport.dataStore.Extension().Extension(portainer.RBACExtension) if !isAdminOrEndpointAdmin {
if err != nil && err != bolterrors.ErrObjectNotFound {
return nil, err
}
endpointResourceAccess := false
_, ok := user.EndpointAuthorizations[portainer.EndpointID(transport.endpoint.ID)][portainer.EndpointResourcesAccess]
if ok {
endpointResourceAccess = true
}
isAdmin := (rbacExtension != nil && endpointResourceAccess) || tokenData.Role == portainer.AdministratorRole
if !isAdmin {
settings, err := transport.dataStore.Settings().Settings() settings, err := transport.dataStore.Settings().Settings()
if err != nil { if err != nil {
return nil, err return nil, err
@ -219,13 +206,17 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
} }
if !settings.AllowDeviceMappingForRegularUsers && len(partialContainer.HostConfig.Devices) > 0 { if !settings.AllowDeviceMappingForRegularUsers && len(partialContainer.HostConfig.Devices) > 0 {
return nil, errors.New("forbidden to use device mapping") return forbiddenResponse, errors.New("forbidden to use device mapping")
} }
if !settings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) { if !settings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
return nil, errors.New("forbidden to use container capabilities") return nil, errors.New("forbidden to use container capabilities")
} }
if !settings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
} }

View File

@ -1,7 +1,11 @@
package docker package docker
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors"
"io/ioutil"
"net/http" "net/http"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -85,3 +89,54 @@ func selectorServiceLabels(responseObject map[string]interface{}) map[string]int
} }
return nil return nil
} }
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
settings, err := transport.dataStore.Settings().Settings()
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialService := &PartialService{}
err = json.Unmarshal(body, partialService)
if err != nil {
return nil, err
}
if !settings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
}
}
request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
}
return transport.replaceRegistryAuthenticationHeader(request)
}

View File

@ -225,7 +225,7 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.
func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) { func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath { switch requestPath := request.URL.Path; requestPath {
case "/services/create": case "/services/create":
return transport.replaceRegistryAuthenticationHeader(request) return transport.decorateServiceCreationOperation(request)
case "/services": case "/services":
return transport.rewriteOperation(request, transport.serviceListOperation) return transport.rewriteOperation(request, transport.serviceListOperation)
@ -629,7 +629,6 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
return nil, err return nil, err
} }
accessContext := &registryAccessContext{ accessContext := &registryAccessContext{
isAdmin: true, isAdmin: true,
userID: tokenData.ID, userID: tokenData.ID,
@ -707,3 +706,32 @@ func (transport *Transport) createOperationContext(request *http.Request) (*rest
return operationContext, nil return operationContext, nil
} }
func (transport *Transport) isAdminOrEndpointAdmin(request *http.Request) (bool, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return false, err
}
if tokenData.Role == portainer.AdministratorRole {
return true, nil
}
user, err := transport.dataStore.User().User(tokenData.ID)
if err != nil {
return false, err
}
rbacExtension, err := transport.dataStore.Extension().Extension(portainer.RBACExtension)
if err != nil && err != bolterrors.ErrObjectNotFound {
return false, err
}
if rbacExtension == nil {
return false, nil
}
_, endpointResourceAccess := user.EndpointAuthorizations[portainer.EndpointID(transport.endpoint.ID)][portainer.EndpointResourcesAccess]
return endpointResourceAccess, nil
}

View File

@ -614,6 +614,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
$scope.isAdmin = Authentication.isAdmin(); $scope.isAdmin = Authentication.isAdmin();
$scope.showDeviceMapping = await shouldShowDevices(); $scope.showDeviceMapping = await shouldShowDevices();
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled(); $scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
$scope.isAdminOrEndpointAdmin = await checkIfAdminOrEndpointAdmin();
Volume.query( Volume.query(
{}, {},
@ -678,7 +679,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
SettingsService.publicSettings() SettingsService.publicSettings()
.then(function success(data) { .then(function success(data) {
$scope.allowBindMounts = data.AllowBindMountsForRegularUsers; $scope.allowBindMounts = $scope.isAdminOrEndpointAdmin || data.AllowBindMountsForRegularUsers;
$scope.allowPrivilegedMode = data.AllowPrivilegedModeForRegularUsers; $scope.allowPrivilegedMode = data.AllowPrivilegedModeForRegularUsers;
}) })
.catch(function error(err) { .catch(function error(err) {
@ -922,6 +923,15 @@ angular.module('portainer.docker').controller('CreateContainerController', [
return allowContainerCapabilitiesForRegularUsers || isAdminOrEndpointAdmin(); return allowContainerCapabilitiesForRegularUsers || isAdminOrEndpointAdmin();
} }
async function checkIfAdminOrEndpointAdmin() {
if (Authentication.isAdmin()) {
return true;
}
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
return rbacEnabled ? Authentication.hasAuthorizations(['EndpointResourcesAccess']) : false;
}
initView(); initView();
}, },
]); ]);

View File

@ -334,8 +334,8 @@
</div> </div>
<!-- !container-path --> <!-- !container-path -->
<!-- volume-type --> <!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;" ng-if="isAdmin || allowBindMounts"> <div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm" ng-if="allowBindMounts">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label> <label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
</div> </div>

View File

@ -33,6 +33,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [
'SettingsService', 'SettingsService',
'WebhookService', 'WebhookService',
'EndpointProvider', 'EndpointProvider',
'ExtensionService',
function ( function (
$q, $q,
$scope, $scope,
@ -58,7 +59,8 @@ angular.module('portainer.docker').controller('CreateServiceController', [
NodeService, NodeService,
SettingsService, SettingsService,
WebhookService, WebhookService,
EndpointProvider EndpointProvider,
ExtensionService
) { ) {
$scope.formValues = { $scope.formValues = {
Name: '', Name: '',
@ -106,6 +108,8 @@ angular.module('portainer.docker').controller('CreateServiceController', [
actionInProgress: false, actionInProgress: false,
}; };
$scope.allowBindMounts = false;
$scope.refreshSlider = function () { $scope.refreshSlider = function () {
$timeout(function () { $timeout(function () {
$scope.$broadcast('rzSliderForceRender'); $scope.$broadcast('rzSliderForceRender');
@ -562,8 +566,8 @@ angular.module('portainer.docker').controller('CreateServiceController', [
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
configs: apiVersion >= 1.3 ? ConfigService.configs() : [], configs: apiVersion >= 1.3 ? ConfigService.configs() : [],
nodes: NodeService.nodes(), nodes: NodeService.nodes(),
settings: SettingsService.publicSettings(),
availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25), availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25),
allowBindMounts: checkIfAllowedBindMounts(),
}) })
.then(function success(data) { .then(function success(data) {
$scope.availableVolumes = data.volumes; $scope.availableVolumes = data.volumes;
@ -572,8 +576,8 @@ angular.module('portainer.docker').controller('CreateServiceController', [
$scope.availableConfigs = data.configs; $scope.availableConfigs = data.configs;
$scope.availableLoggingDrivers = data.availableLoggingDrivers; $scope.availableLoggingDrivers = data.availableLoggingDrivers;
initSlidersMaxValuesBasedOnNodeData(data.nodes); initSlidersMaxValuesBasedOnNodeData(data.nodes);
$scope.allowBindMounts = data.settings.AllowBindMountsForRegularUsers;
$scope.isAdmin = Authentication.isAdmin(); $scope.isAdmin = Authentication.isAdmin();
$scope.allowBindMounts = data.allowBindMounts;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to initialize view'); Notifications.error('Failure', err, 'Unable to initialize view');
@ -581,5 +585,22 @@ angular.module('portainer.docker').controller('CreateServiceController', [
} }
initView(); initView();
async function checkIfAllowedBindMounts() {
const isAdmin = Authentication.isAdmin();
const settings = await SettingsService.publicSettings();
const { AllowBindMountsForRegularUsers } = settings;
if (isAdmin || AllowBindMountsForRegularUsers) {
return true;
}
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
if (rbacEnabled) {
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
}
return false;
}
}, },
]); ]);

View File

@ -305,7 +305,7 @@
<!-- !container-path --> <!-- !container-path -->
<!-- volume-type --> <!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;"> <div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm" ng-if="isAdmin || allowBindMounts"> <div class="btn-group btn-group-sm" ng-if="allowBindMounts">
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label> <label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label> <label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label>
</div> </div>