fix(stacks): validate stacks with env vars [BE-12689] (#2051)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/dotenv"
|
||||
)
|
||||
|
||||
// BuildEnvMap builds the environment variable map for stack validation/loading.
|
||||
// Priority (lowest to highest): OS env → .env file → stack.Env
|
||||
func BuildEnvMap(stack *portainer.Stack) map[string]string {
|
||||
env := make(map[string]string, len(os.Environ()))
|
||||
for _, e := range os.Environ() {
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
dotEnvPath := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
|
||||
if dotVars, err := dotenv.Read(dotEnvPath); err == nil {
|
||||
maps.Copy(env, dotVars)
|
||||
}
|
||||
|
||||
for _, pair := range stack.Env {
|
||||
env[pair.Name] = pair.Value
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"context"
|
||||
"path"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/compose/types"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
composeloader "github.com/compose-spec/compose-go/v2/loader"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
|
||||
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
|
||||
type StackFileValidationConfig struct {
|
||||
Content []byte
|
||||
SecuritySettings *portainer.EndpointSecuritySettings
|
||||
Env map[string]string
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
func IsValidStackFile(config StackFileValidationConfig) error {
|
||||
composeConfigDetails := composetypes.ConfigDetails{
|
||||
ConfigFiles: []composetypes.ConfigFile{{Content: config.Content}},
|
||||
Environment: config.Env,
|
||||
WorkingDir: config.WorkingDir,
|
||||
}
|
||||
|
||||
composeConfig, err := composeloader.LoadWithContext(context.Background(), composeConfigDetails, composeloader.WithSkipValidation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
composeConfigFile := types.ConfigFile{
|
||||
Config: composeConfigYAML,
|
||||
}
|
||||
|
||||
composeConfigDetails := types.ConfigDetails{
|
||||
ConfigFiles: []types.ConfigFile{composeConfigFile},
|
||||
Environment: map[string]string{},
|
||||
}
|
||||
|
||||
composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) {
|
||||
options.SkipValidation = true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key := range composeConfig.Services {
|
||||
service := composeConfig.Services[key]
|
||||
if !securitySettings.AllowBindMountsForRegularUsers {
|
||||
for _, service := range composeConfig.Services {
|
||||
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
|
||||
for _, volume := range service.Volumes {
|
||||
if volume.Type == "bind" {
|
||||
return errors.New("bind-mount disabled for non administrator users")
|
||||
@@ -40,23 +40,23 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
||||
}
|
||||
}
|
||||
|
||||
if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
|
||||
if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
|
||||
return errors.New("privileged mode disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
||||
if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
||||
return errors.New("pid host disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
|
||||
if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
|
||||
return errors.New("device mapping disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
|
||||
if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
|
||||
return errors.New("sysctl setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||
if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||
return errors.New("container capabilities disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,21 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
||||
}
|
||||
|
||||
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
|
||||
env := BuildEnvMap(stack)
|
||||
workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint))
|
||||
|
||||
for _, file := range GetStackFilePaths(stack, false) {
|
||||
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get stack file content")
|
||||
}
|
||||
|
||||
if err := IsValidStackFile(stackContent, securitySettings); err != nil {
|
||||
if err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: stackContent,
|
||||
SecuritySettings: securitySettings,
|
||||
Env: env,
|
||||
WorkingDir: workingDir,
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "stack config file is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -30,32 +31,236 @@ networks:
|
||||
`)
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := IsValidStackFile(yamlContent, securitySettings)
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: yamlContent,
|
||||
SecuritySettings: securitySettings,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIsValidStackFile_PortEnv(t *testing.T) {
|
||||
yamlContent := []byte(`
|
||||
// TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects
|
||||
// validation when the env var is not provided. Docker accepts an empty host port (left side)
|
||||
// but requires a valid container port (right side).
|
||||
func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) {
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
|
||||
t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) {
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "${API_PORT}:9090"
|
||||
`),
|
||||
SecuritySettings: securitySettings,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("var on right side fails", func(t *testing.T) {
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "9090:${API_PORT}"
|
||||
`),
|
||||
SecuritySettings: securitySettings,
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("var on both sides fails", func(t *testing.T) {
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "${API_PORT}:${API_PORT}"
|
||||
`),
|
||||
SecuritySettings: securitySettings,
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) {
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: []byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
webservice:
|
||||
api:
|
||||
image: nginx
|
||||
container_name: hello-world
|
||||
networks:
|
||||
- "mynet1"
|
||||
ports:
|
||||
- "${PORT}:80"
|
||||
|
||||
networks:
|
||||
mynet1:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.16.0.0/24
|
||||
`)
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := IsValidStackFile(yamlContent, securitySettings)
|
||||
- "${API_PORT}:${API_PORT}"
|
||||
`),
|
||||
SecuritySettings: securitySettings,
|
||||
Env: map[string]string{"API_PORT": "3000"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type mockFileService struct {
|
||||
portainer.FileService
|
||||
fileContent []byte
|
||||
projectVersionPath string
|
||||
}
|
||||
|
||||
func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) {
|
||||
return m.fileContent, nil
|
||||
}
|
||||
|
||||
func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string {
|
||||
return m.projectVersionPath
|
||||
}
|
||||
|
||||
func TestValidateStackFiles_EnvVars(t *testing.T) {
|
||||
fileContent := []byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "${API_PORT}:${API_PORT}"
|
||||
`)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}},
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: fileContent,
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateStackFiles_OSEnvVar(t *testing.T) {
|
||||
t.Setenv("HOST_PORT", "3000")
|
||||
|
||||
fileContent := []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:${HOST_PORT}"
|
||||
`)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: fileContent,
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateStackFiles_DotEnvFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
fileContent := []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:${HOST_PORT}"
|
||||
`)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: tmpDir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: fileContent,
|
||||
projectVersionPath: tmpDir,
|
||||
}
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err = ValidateStackFiles(stack, securitySettings, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateStackFiles_EnvFileAttribute(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
fileContent := []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
env_file:
|
||||
- ./web.env
|
||||
`)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: tmpDir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: fileContent,
|
||||
projectVersionPath: tmpDir,
|
||||
}
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err = ValidateStackFiles(stack, securitySettings, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) {
|
||||
fileContent := []byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
volumes:
|
||||
- /host/path:/container/path
|
||||
`)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: fileContent,
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
}
|
||||
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||
require.ErrorContains(t, err, "bind-mount disabled for non administrator users")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user