package docker import ( "bytes" "net/http" "net/http/httptest" "testing" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/swarm" "github.com/segmentio/encoding/json" "github.com/stretchr/testify/require" ) const serviceCreationAPIVersion = "1.51" type serviceCreationFixtures struct { dockerSrv *httptest.Server ds dataservices.DataStore stdUser portainer.User adminUser portainer.User endpointID portainer.EndpointID } func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures { t.Helper() const serviceID = "some-service-id" dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead && r.URL.Path == "/_ping" { w.Header().Add("Api-Version", serviceCreationAPIVersion) _, _ = w.Write([]byte{}) return } if r.Method == http.MethodPost { data, err := json.Marshal(map[string]string{"ID": serviceID}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write(data) return } http.NotFound(w, r) })) t.Cleanup(dockerSrv.Close) _, store := datastore.MustNewTestStore(t, true, false) f := &serviceCreationFixtures{ dockerSrv: dockerSrv, ds: store, stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole}, adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}, endpointID: portainer.EndpointID(1), } err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := tx.User().Create(&f.stdUser) require.NoError(t, err) err = tx.User().Create(&f.adminUser) require.NoError(t, err) err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"}) require.NoError(t, err) return nil }) require.NoError(t, err) return f } func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) { t.Helper() err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error { return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{ ID: f.endpointID, Name: "test-env", SecuritySettings: settings, }) }) require.NoError(t, err) } func (f *serviceCreationFixtures) newTransport() *Transport { return &Transport{ endpoint: &portainer.Endpoint{ID: f.endpointID}, dataStore: f.ds, HTTPTransport: &http.Transport{}, } } func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request { t.Helper() data, err := json.Marshal(spec) require.NoError(t, err) req, err := http.NewRequestWithContext( t.Context(), http.MethodPost, f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create", bytes.NewReader(data), ) require.NoError(t, err) return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ ID: user.ID, Username: user.Username, Role: user.Role, })) } var ( restrictiveSettings = portainer.EndpointSecuritySettings{ AllowContainerCapabilitiesForRegularUsers: false, AllowSysctlSettingForRegularUsers: false, AllowSecurityOptForRegularUsers: false, AllowBindMountsForRegularUsers: false, } permissiveSettings = portainer.EndpointSecuritySettings{ AllowContainerCapabilitiesForRegularUsers: true, AllowSysctlSettingForRegularUsers: true, AllowSecurityOptForRegularUsers: true, AllowBindMountsForRegularUsers: true, } ) func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ CapabilityAdd: []string{"NET_ADMIN"}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ CapabilityDrop: []string{"MKNOD"}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ CapabilityAdd: []string{"NET_ADMIN"}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) var spec swarm.ServiceSpec resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrSysCtlSettingsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_SeccompForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Privileges: &swarm.Privileges{ Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, }, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_AppArmorForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Privileges: &swarm.Privileges{ AppArmor: &swarm.AppArmorOpts{Mode: swarm.AppArmorModeDefault}, }, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_NilPrivilegesNotForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) var spec swarm.ServiceSpec resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_PrivilegesWithNilSeccompAndAppArmorNotForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Privileges: &swarm.Privileges{}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_PrivilegesAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Privileges: &swarm.Privileges{ Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, }, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{Type: mount.TypeBind}}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, portainer.EndpointSecuritySettings{ AllowContainerCapabilitiesForRegularUsers: true, AllowSysctlSettingForRegularUsers: true, AllowSecurityOptForRegularUsers: true, AllowBindMountsForRegularUsers: false, }) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{Type: mount.TypeVolume}}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NotErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{Type: mount.TypeBind}}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ CapabilityAdd: []string{"NET_ADMIN"}, CapabilityDrop: []string{"MKNOD"}, Sysctls: map[string]string{"net.ipv4.ip_forward": "1"}, Privileges: &swarm.Privileges{ Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom}, }, Mounts: []mount.Mount{{Type: mount.TypeBind}}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser)) require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden) require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden) require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden) require.NotErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ CapabilityAdd: []string{"NET_ADMIN"}, Sysctls: map[string]string{"net.core.somaxconn": "128"}, Mounts: []mount.Mount{{Type: mount.TypeBind}}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.Equal(t, http.StatusCreated, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{ Type: mount.TypeVolume, VolumeOptions: &mount.VolumeOptions{ DriverConfig: &mount.Driver{ Options: map[string]string{"type": "bind", "device": "/etc"}, }, }, }}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.ErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, permissiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{ Type: mount.TypeVolume, VolumeOptions: &mount.VolumeOptions{ DriverConfig: &mount.Driver{ Options: map[string]string{"type": "bind", "device": "/etc"}, }, }, }}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{ Type: mount.TypeVolume, VolumeOptions: &mount.VolumeOptions{ DriverConfig: &mount.Driver{ Options: map[string]string{"type": "tmpfs"}, }, }, }}, }, }, } resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser)) require.NotErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.NotEqual(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) } func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) { t.Parallel() f := newServiceCreationFixtures(t) f.setSecuritySettings(t, restrictiveSettings) spec := swarm.ServiceSpec{ TaskTemplate: swarm.TaskSpec{ ContainerSpec: &swarm.ContainerSpec{ Mounts: []mount.Mount{{ Type: mount.TypeVolume, VolumeOptions: &mount.VolumeOptions{ DriverConfig: &mount.Driver{ Options: map[string]string{"type": "bind", "device": "/etc"}, }, }, }}, }, }, } resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id") require.ErrorIs(t, err, ErrBindMountsForbidden) require.NotNil(t, resp) require.Equal(t, http.StatusForbidden, resp.StatusCode) err = resp.Body.Close() require.NoError(t, err) }