mirror of https://github.com/portainer/portainer
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 typopull/4126/head
parent
7539f09f98
commit
93d8c179f1
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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 := ®istryAccessContext{
|
accessContext := ®istryAccessContext{
|
||||||
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
|
||||||
|
}
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue