From 5a19f66a376d55d2d70e9b0eec1c2fa67375d05c Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 18 Mar 2026 01:05:16 +0200 Subject: [PATCH] fix(stacks): validate stacks with env vars [BE-12689] (#2051) --- api/stacks/stackutils/env.go | 34 ++++ api/stacks/stackutils/validation.go | 68 ++++--- api/stacks/stackutils/validation_test.go | 245 +++++++++++++++++++++-- go.mod | 3 - go.sum | 7 - 5 files changed, 297 insertions(+), 60 deletions(-) create mode 100644 api/stacks/stackutils/env.go diff --git a/api/stacks/stackutils/env.go b/api/stacks/stackutils/env.go new file mode 100644 index 000000000..f8aa815be --- /dev/null +++ b/api/stacks/stackutils/env.go @@ -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 +} diff --git a/api/stacks/stackutils/validation.go b/api/stacks/stackutils/validation.go index 1c4c71162..5c47515ff 100644 --- a/api/stacks/stackutils/validation.go +++ b/api/stacks/stackutils/validation.go @@ -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") } } diff --git a/api/stacks/stackutils/validation_test.go b/api/stacks/stackutils/validation_test.go index 9c8229990..28262dcea 100644 --- a/api/stacks/stackutils/validation_test.go +++ b/api/stacks/stackutils/validation_test.go @@ -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") +} diff --git a/go.mod b/go.mod index 3de9a0f21..32ac50c7f 100644 --- a/go.mod +++ b/go.mod @@ -265,9 +265,6 @@ require ( github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/zclconf/go-cty v1.17.0 // indirect diff --git a/go.sum b/go.sum index 6781e5f71..7b43220c5 100644 --- a/go.sum +++ b/go.sum @@ -748,13 +748,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=