chore: import upstream snapshot with attribution
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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/git"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStackAlreadyExists = errors.New("A stack already exists with this name")
|
||||
ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
|
||||
)
|
||||
|
||||
// DownloadGitRepository downloads the target git repository on the disk
|
||||
// The first return value represents the commit hash of the downloaded git repository
|
||||
func DownloadGitRepository(ctx context.Context, config gittypes.RepoConfig, gitService portainer.GitService, getProjectPath func() string) (string, error) {
|
||||
username := ""
|
||||
password := ""
|
||||
if config.Authentication != nil {
|
||||
username = config.Authentication.Username
|
||||
password = config.Authentication.Password
|
||||
}
|
||||
|
||||
projectPath := getProjectPath()
|
||||
err := gitService.CloneRepository(
|
||||
ctx,
|
||||
projectPath,
|
||||
config.URL,
|
||||
config.ReferenceName,
|
||||
username,
|
||||
password,
|
||||
config.TLSSkipVerify,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
||||
newErr := git.ErrInvalidGitCredential
|
||||
return "", newErr
|
||||
}
|
||||
|
||||
newErr := fmt.Errorf("unable to clone git repository: %w", err)
|
||||
return "", newErr
|
||||
}
|
||||
|
||||
commitID, err := gitService.LatestCommitID(
|
||||
ctx,
|
||||
config.URL,
|
||||
config.ReferenceName,
|
||||
username,
|
||||
password,
|
||||
config.TLSSkipVerify,
|
||||
)
|
||||
if err != nil {
|
||||
newErr := fmt.Errorf("unable to fetch git repository id: %w", err)
|
||||
return "", newErr
|
||||
}
|
||||
return commitID, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/git"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockGitSvc struct {
|
||||
cloneErr error
|
||||
commitID string
|
||||
commitErr error
|
||||
}
|
||||
|
||||
func (m *mockGitSvc) CloneRepository(_ context.Context, _, _, _, _, _ string, _ bool) error {
|
||||
return m.cloneErr
|
||||
}
|
||||
|
||||
func (m *mockGitSvc) LatestCommitID(_ context.Context, _, _, _, _ string, _ bool) (string, error) {
|
||||
return m.commitID, m.commitErr
|
||||
}
|
||||
|
||||
func (m *mockGitSvc) ListRefs(_ context.Context, _, _, _ string, _ bool, _ bool) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGitSvc) ListFiles(_ context.Context, _, _, _, _ string, _, _ bool, _ []string, _ bool) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ portainer.GitService = (*mockGitSvc)(nil)
|
||||
|
||||
func TestDownloadGitRepository_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &mockGitSvc{commitID: "abc123"}
|
||||
cfg := gittypes.RepoConfig{
|
||||
URL: "https://github.com/x/repo",
|
||||
ReferenceName: "refs/heads/main",
|
||||
}
|
||||
|
||||
commitID, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() })
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "abc123", commitID)
|
||||
}
|
||||
|
||||
func TestDownloadGitRepository_NilAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &mockGitSvc{commitID: "deadbeef"}
|
||||
cfg := gittypes.RepoConfig{
|
||||
URL: "https://github.com/x/repo",
|
||||
Authentication: nil,
|
||||
}
|
||||
|
||||
commitID, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() })
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "deadbeef", commitID)
|
||||
}
|
||||
|
||||
func TestDownloadGitRepository_AuthenticationFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &mockGitSvc{cloneErr: gittypes.ErrAuthenticationFailure}
|
||||
cfg := gittypes.RepoConfig{URL: "https://github.com/x/private"}
|
||||
|
||||
_, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() })
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, git.ErrInvalidGitCredential)
|
||||
}
|
||||
|
||||
func TestDownloadGitRepository_OtherCloneError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cloneErr := errors.New("network timeout")
|
||||
svc := &mockGitSvc{cloneErr: cloneErr}
|
||||
cfg := gittypes.RepoConfig{URL: "https://github.com/x/repo"}
|
||||
|
||||
_, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() })
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, cloneErr)
|
||||
require.NotErrorIs(t, err, git.ErrInvalidGitCredential)
|
||||
}
|
||||
|
||||
func TestDownloadGitRepository_LatestCommitIDError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
commitErr := errors.New("remote unreachable")
|
||||
svc := &mockGitSvc{commitErr: commitErr}
|
||||
cfg := gittypes.RepoConfig{URL: "https://github.com/x/repo"}
|
||||
|
||||
_, err := DownloadGitRepository(t.Context(), cfg, svc, func() string { return t.TempDir() })
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, commitErr)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// PrepareStackStatusForDeployment transitions a stack into the deploying state before a deployment begins.
|
||||
// It saves the current status in DeploymentStartStatus so that pre-deployment actions can be determined
|
||||
// (e.g. whether to undeploy before redeploying), sets Status to StackStatusDeploying, and resets the
|
||||
// DeploymentStatus history with a single deploying entry.
|
||||
func PrepareStackStatusForDeployment(stack *portainer.Stack) {
|
||||
stack.DeploymentStartStatus = stack.Status
|
||||
stack.Status = portainer.StackStatusDeploying
|
||||
stack.DeploymentStatus = []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: time.Now().Unix()},
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateStackStatusFromDeploymentResult updates a stack's status after a deployment attempt completes.
|
||||
// On success (err == nil) the stack is marked active; on failure it is marked as errored with the error message recorded.
|
||||
func UpdateStackStatusFromDeploymentResult(stack *portainer.Stack, err error) {
|
||||
if err != nil {
|
||||
stack.Status = portainer.StackStatusError
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusError,
|
||||
Time: time.Now().Unix(),
|
||||
Message: err.Error(),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stack.Status = portainer.StackStatusActive
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusActive,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateStackStatusFromUndeploymentResult updates a stack's status after an undeployment attempt completes.
|
||||
// On success (err == nil) the stack is marked inactive; on failure it is marked as errored with the error message recorded.
|
||||
func UpdateStackStatusFromUndeploymentResult(stack *portainer.Stack, err error) {
|
||||
if err != nil {
|
||||
stack.Status = portainer.StackStatusError
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusError,
|
||||
Time: time.Now().Unix(),
|
||||
Message: err.Error(),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stack.Status = portainer.StackStatusInactive
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusInactive,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrepareStackStatusForDeployment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stack := &portainer.Stack{
|
||||
Status: portainer.StackStatusActive,
|
||||
}
|
||||
|
||||
PrepareStackStatusForDeployment(stack)
|
||||
|
||||
require.Equal(t, portainer.StackStatusActive, stack.DeploymentStartStatus)
|
||||
require.Equal(t, portainer.StackStatusDeploying, stack.Status)
|
||||
require.Len(t, stack.DeploymentStatus, 1)
|
||||
require.Equal(t, portainer.StackStatusDeploying, stack.DeploymentStatus[0].Status)
|
||||
require.Positive(t, stack.DeploymentStatus[0].Time)
|
||||
}
|
||||
|
||||
func TestUpdateStackStatusFromDeploymentResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("on error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stack := &portainer.Stack{}
|
||||
deployErr := errors.New("deployment failed")
|
||||
|
||||
UpdateStackStatusFromDeploymentResult(stack, deployErr)
|
||||
|
||||
require.Equal(t, portainer.StackStatusError, stack.Status)
|
||||
require.Len(t, stack.DeploymentStatus, 1)
|
||||
require.Equal(t, portainer.StackStatusError, stack.DeploymentStatus[0].Status)
|
||||
require.Equal(t, deployErr.Error(), stack.DeploymentStatus[0].Message)
|
||||
require.Positive(t, stack.DeploymentStatus[0].Time)
|
||||
})
|
||||
|
||||
t.Run("on success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stack := &portainer.Stack{}
|
||||
|
||||
UpdateStackStatusFromDeploymentResult(stack, nil)
|
||||
|
||||
require.Equal(t, portainer.StackStatusActive, stack.Status)
|
||||
require.Len(t, stack.DeploymentStatus, 1)
|
||||
require.Equal(t, portainer.StackStatusActive, stack.DeploymentStatus[0].Status)
|
||||
require.Empty(t, stack.DeploymentStatus[0].Message)
|
||||
require.Positive(t, stack.DeploymentStatus[0].Time)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateStackStatusFromUndeploymentResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("on error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stack := &portainer.Stack{}
|
||||
undeployErr := errors.New("undeployment failed")
|
||||
|
||||
UpdateStackStatusFromUndeploymentResult(stack, undeployErr)
|
||||
|
||||
require.Equal(t, portainer.StackStatusError, stack.Status)
|
||||
require.Len(t, stack.DeploymentStatus, 1)
|
||||
require.Equal(t, portainer.StackStatusError, stack.DeploymentStatus[0].Status)
|
||||
require.Equal(t, undeployErr.Error(), stack.DeploymentStatus[0].Message)
|
||||
require.Positive(t, stack.DeploymentStatus[0].Time)
|
||||
})
|
||||
|
||||
t.Run("on success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stack := &portainer.Stack{}
|
||||
|
||||
UpdateStackStatusFromUndeploymentResult(stack, nil)
|
||||
|
||||
require.Equal(t, portainer.StackStatusInactive, stack.Status)
|
||||
require.Len(t, stack.DeploymentStatus, 1)
|
||||
require.Equal(t, portainer.StackStatusInactive, stack.DeploymentStatus[0].Status)
|
||||
require.Empty(t, stack.DeploymentStatus[0].Message)
|
||||
require.Positive(t, stack.DeploymentStatus[0].Time)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
func UserIsAdminOrEndpointAdmin(user *portainer.User) bool {
|
||||
return user.Role == portainer.AdministratorRole
|
||||
}
|
||||
|
||||
// GetStackFilePaths returns a list of file paths based on stack project path
|
||||
// If absolute is false, the path sanitization step will be skipped, which makes the returning
|
||||
// paths vulnerable to path traversal attacks. Thus, the followed function using the returning
|
||||
// paths are responsible to sanitize the raw paths
|
||||
// If absolute is true, the raw paths will be sanitized
|
||||
func GetStackFilePaths(stack *portainer.Stack, absolute bool) []string {
|
||||
if !absolute {
|
||||
return append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
|
||||
}
|
||||
|
||||
var filePaths []string
|
||||
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
|
||||
filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file))
|
||||
}
|
||||
|
||||
return filePaths
|
||||
}
|
||||
|
||||
// ResourceControlID returns the stack resource control id
|
||||
func ResourceControlID(endpointID portainer.EndpointID, name string) string {
|
||||
return fmt.Sprintf("%d_%s", endpointID, name)
|
||||
}
|
||||
|
||||
// convert string to valid kubernetes label by replacing invalid characters with periods and removing any periods at the beginning or end of the string
|
||||
func SanitizeLabel(value string) string {
|
||||
re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`)
|
||||
onlyAllowedCharacterString := re.ReplaceAllString(value, ".")
|
||||
return strings.Trim(onlyAllowedCharacterString, ".-_")
|
||||
}
|
||||
|
||||
// IsRelativePathStack checks if the stack is a git stack or not
|
||||
func IsRelativePathStack(stack *portainer.Stack) bool {
|
||||
// Always return false in CE
|
||||
// This function is only for code consistency with EE
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_GetStackFilePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "file-one.yml",
|
||||
}
|
||||
|
||||
t.Run("stack doesn't have additional files", func(t *testing.T) {
|
||||
expected := []string{"/tmp/stack/1/file-one.yml"}
|
||||
assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true))
|
||||
})
|
||||
|
||||
t.Run("stack has additional files", func(t *testing.T) {
|
||||
stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"}
|
||||
expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"}
|
||||
assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
composeloader "github.com/compose-spec/compose-go/v2/loader"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
|
||||
return errors.New("privileged mode disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
||||
return errors.New("pid host disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
|
||||
return errors.New("device mapping disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
|
||||
return errors.New("sysctl setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowSecurityOptForRegularUsers && len(service.SecurityOpt) > 0 {
|
||||
return errors.New("security-opt setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||
return errors.New("container capabilities disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateComposeURLs parses each stack file and checks that every external URL
|
||||
// (build contexts and image registry hostnames) is permitted by the active SSRF
|
||||
// policy. It is a no-op when SSRF protection is disabled.
|
||||
func ValidateComposeURLs(ctx context.Context, stack *portainer.Stack, fileService portainer.FileService) error {
|
||||
if !ssrf.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 := checkComposeFileURLs(ctx, stackContent, env, workingDir); err != nil {
|
||||
return errors.Wrap(err, "stack file contains a URL blocked by the SSRF policy")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEdgeStackComposeContent checks that every external URL in an edge
|
||||
// stack's Compose file is permitted by the active SSRF policy. It is a no-op
|
||||
// when SSRF protection is disabled or the deployment type is not compose.
|
||||
func ValidateEdgeStackComposeContent(ctx context.Context, deploymentType portainer.EdgeStackDeploymentType, content []byte) error {
|
||||
if !ssrf.IsEnabled() || deploymentType != portainer.EdgeStackDeploymentCompose {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := checkComposeFileURLs(ctx, content, nil, ""); err != nil {
|
||||
return errors.Wrap(err, "stack file contains a URL blocked by the SSRF policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkComposeFileURLs(ctx context.Context, content []byte, env map[string]string, workingDir string) error {
|
||||
composeConfigDetails := composetypes.ConfigDetails{
|
||||
ConfigFiles: []composetypes.ConfigFile{{Content: content}},
|
||||
Environment: env,
|
||||
WorkingDir: workingDir,
|
||||
}
|
||||
|
||||
composeConfig, err := composeloader.LoadWithContext(ctx, composeConfigDetails, composeloader.WithSkipValidation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, service := range composeConfig.Services {
|
||||
if service.Build != nil {
|
||||
buildCtx := service.Build.Context
|
||||
if strings.HasPrefix(buildCtx, "http://") || strings.HasPrefix(buildCtx, "https://") {
|
||||
if err := ssrf.CheckURL(ctx, buildCtx); err != nil {
|
||||
return fmt.Errorf("service %q: build context URL blocked: %w", service.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if service.Image != "" {
|
||||
if registry := extractImageRegistry(service.Image); registry != "" {
|
||||
if err := ssrf.CheckURL(ctx, "https://"+registry); err != nil {
|
||||
return fmt.Errorf("service %q: image registry %q blocked: %w", service.Name, registry, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractImageRegistry returns the registry hostname from an OCI image reference,
|
||||
// or an empty string if the image resolves to Docker Hub (no explicit registry).
|
||||
func extractImageRegistry(imageRef string) string {
|
||||
ref, _, _ := strings.Cut(imageRef, "@")
|
||||
|
||||
first, _, hasSlash := strings.Cut(ref, "/")
|
||||
if !hasSlash {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.ContainsAny(first, ".:") || first == "localhost" {
|
||||
return first
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
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(StackFileValidationConfig{
|
||||
Content: stackContent,
|
||||
SecuritySettings: securitySettings,
|
||||
Env: env,
|
||||
WorkingDir: workingDir,
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "stack config file is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsValidStackFile_DefaultPortEnvSubstitution(t *testing.T) {
|
||||
t.Parallel()
|
||||
yamlContent := []byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
webservice:
|
||||
image: nginx
|
||||
container_name: hello-world
|
||||
networks:
|
||||
- "mynet1"
|
||||
ports:
|
||||
- "${PORT:-8080}:80"
|
||||
|
||||
networks:
|
||||
mynet1:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.16.0.0/24
|
||||
`)
|
||||
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: yamlContent,
|
||||
SecuritySettings: securitySettings,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
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) {
|
||||
t.Parallel()
|
||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||
err := IsValidStackFile(StackFileValidationConfig{
|
||||
Content: []byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
ports:
|
||||
- "${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) {
|
||||
t.Parallel()
|
||||
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) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0o600)
|
||||
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) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0o600)
|
||||
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) {
|
||||
t.Parallel()
|
||||
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")
|
||||
}
|
||||
|
||||
func TestExtractImageRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
image string
|
||||
expected string
|
||||
}{
|
||||
{"nginx", ""},
|
||||
{"nginx:latest", ""},
|
||||
{"library/nginx", ""},
|
||||
{"ghcr.io/owner/image:tag", "ghcr.io"},
|
||||
{"myregistry.com/image:tag", "myregistry.com"},
|
||||
{"myregistry.com:5000/image:tag", "myregistry.com:5000"},
|
||||
{"localhost/image:tag", "localhost"},
|
||||
{"localhost:5000/image:tag", "localhost:5000"},
|
||||
{"myregistry.com/image@sha256:abc", "myregistry.com"},
|
||||
{"169.254.169.254/image:tag", "169.254.169.254"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := extractImageRegistry(tc.image)
|
||||
require.Equal(t, tc.expected, got, "image: %s", tc.image)
|
||||
}
|
||||
}
|
||||
|
||||
type staticAllowListService struct {
|
||||
parsed portainer.ParsedAllowList
|
||||
}
|
||||
|
||||
func (s *staticAllowListService) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
|
||||
return &s.parsed, nil
|
||||
}
|
||||
|
||||
func configureSSRF(t *testing.T, mode portainer.SSRFMode, entries []string) {
|
||||
t.Helper()
|
||||
|
||||
parsed := ssrf.ParseAllowedHosts(entries)
|
||||
parsed.Mode = mode
|
||||
err := ssrf.Configure(&staticAllowListService{parsed: parsed})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := ssrf.Configure(&staticAllowListService{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_DisabledSSRF(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeOff, nil)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: http://169.254.169.254/repo.tar.gz
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_BuildContextBlocked(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"})
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: http://169.254.169.254/repo.tar.gz
|
||||
image: nginx
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.ErrorContains(t, err, "SSRF policy")
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_BuildContextPath(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"})
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ./app
|
||||
image: nginx
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_ImageRegistryBlocked(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"})
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: 169.254.169.254/myimage:latest
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.ErrorContains(t, err, "SSRF policy")
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_ImageRegistryAllowed(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeEnforce, []string{"myregistry.com"})
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: myregistry.com/myimage:latest
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateComposeURLs_DockerHubImageAllowed(t *testing.T) {
|
||||
configureSSRF(t, portainer.SSRFModeEnforce, []string{"example.com"})
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
}
|
||||
|
||||
fileService := mockFileService{
|
||||
fileContent: []byte(`
|
||||
version: "3"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
`),
|
||||
projectVersionPath: "/tmp/stack/1",
|
||||
}
|
||||
|
||||
err := ValidateComposeURLs(t.Context(), stack, fileService)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user