mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 11:04:49 +00:00
fix(swarm): swarm config + secrets path traversal [BE-13200] (#3181)
This commit is contained in:
@@ -716,7 +716,7 @@ func getConfigDetails(filePaths []string, workingDir string, env []string) (comp
|
||||
root = composeDir
|
||||
}
|
||||
|
||||
resolveEnvFilePaths(config, root, composeDirRel)
|
||||
resolveRelativePaths(config, root, composeDirRel)
|
||||
|
||||
details.ConfigFiles = append(details.ConfigFiles, composetypes.ConfigFile{
|
||||
Filename: fp,
|
||||
@@ -747,6 +747,15 @@ func getConfigDetails(filePaths []string, workingDir string, env []string) (comp
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// resolveRelativePaths rewrites the on-disk paths a compose file references (service env_file
|
||||
// entries and secret/config file entries) to absolute paths clamped under root, so that a relative
|
||||
// "../" can reach a sibling directory within the project root but can never escape above it.
|
||||
func resolveRelativePaths(rawConfig map[string]any, root, composeDirRel string) {
|
||||
resolveEnvFilePaths(rawConfig, root, composeDirRel)
|
||||
resolveFileObjectPaths(rawConfig, "secrets", root, composeDirRel)
|
||||
resolveFileObjectPaths(rawConfig, "configs", root, composeDirRel)
|
||||
}
|
||||
|
||||
// resolveEnvFilePaths rewrites each service's relative env_file to an absolute path. Paths are
|
||||
// authored relative to the compose file's directory (composeDirRel, relative to root) and are
|
||||
// resolved under root via filesystem.JoinPaths. Because JoinPaths keeps the result clamped to root,
|
||||
@@ -781,6 +790,30 @@ func resolveEnvFilePaths(rawConfig map[string]any, root, composeDirRel string) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFileObjectPaths rewrites relative file entries under a top-level section (secrets or
|
||||
// configs) to absolute paths clamped under root via filesystem.JoinPaths, using the same semantics
|
||||
// as resolveEnvFilePaths. External and driver-based secrets/configs carry no file key and are
|
||||
// skipped. Unlike env_file, the file field is always a single string.
|
||||
func resolveFileObjectPaths(rawConfig map[string]any, section, root, composeDirRel string) {
|
||||
objs, ok := rawConfig[section].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, objAny := range objs {
|
||||
obj, ok := objAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fileAny, ok := obj["file"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s, ok := fileAny.(string); ok && !filepath.IsAbs(s) {
|
||||
obj["file"] = filesystem.JoinPaths(root, composeDirRel, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForStatus blocks until all services in the stack reach the requested status,
|
||||
// or the context is cancelled. It polls the Docker API every second.
|
||||
func (d *SwarmDeployer) WaitForStatus(
|
||||
|
||||
@@ -809,6 +809,213 @@ services:
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret file with relative path is resolved against the compose file directory",
|
||||
composeFiles: map[string]string{
|
||||
"docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
file: secret.txt`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {File: dir + "/secret.txt"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config file with relative path is resolved against the compose file directory",
|
||||
composeFiles: map[string]string{
|
||||
"docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
configs:
|
||||
app_config:
|
||||
file: app.conf`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{
|
||||
"app_config": {File: dir + "/app.conf"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A compose file in a sub-directory must be able to reach a secret file that lives
|
||||
// outside that sub-directory (e.g. an edge-config directory above it) via a "../"
|
||||
// relative path. The traversal has to be honored, not stripped.
|
||||
name: "secret file with relative path traverses out of the compose file sub-directory",
|
||||
composeFiles: map[string]string{
|
||||
"sub/docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
file: ../edge-configs/prod/secret.txt`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/sub/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {File: dir + "/edge-configs/prod/secret.txt"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A "../" traversal that would escape the working dir must be clamped to it, so a
|
||||
// malicious compose file cannot reference files outside the project directory.
|
||||
name: "secret file with relative path traversing outside the working dir is clamped",
|
||||
composeFiles: map[string]string{
|
||||
"docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
file: ../../../etc/passwd`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {File: dir + "/etc/passwd"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret file with absolute path is left unchanged",
|
||||
composeFiles: map[string]string{
|
||||
"docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
file: /etc/portainer/secret.txt`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {File: "/etc/portainer/secret.txt"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "external secret has no file and is left unchanged",
|
||||
composeFiles: map[string]string{
|
||||
"docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
external: true`,
|
||||
},
|
||||
workingDir: dir,
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {External: composetypes.External{External: true}, Name: "app_secret"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative secret file with no working dir is resolved against the compose file directory",
|
||||
composeFiles: map[string]string{
|
||||
"sub/docker-compose.yaml": `services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
secrets:
|
||||
app_secret:
|
||||
file: secret.txt`,
|
||||
},
|
||||
expectedCfg: &composetypes.Config{
|
||||
Filename: dir + "/sub/docker-compose.yaml",
|
||||
Version: "3.13",
|
||||
Services: composetypes.Services{
|
||||
composetypes.ServiceConfig{
|
||||
Name: "web",
|
||||
Environment: composetypes.MappingWithEquals{},
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
},
|
||||
Networks: map[string]composetypes.NetworkConfig{},
|
||||
Volumes: map[string]composetypes.VolumeConfig{},
|
||||
Secrets: map[string]composetypes.SecretConfig{
|
||||
"app_secret": {File: dir + "/sub/secret.txt"},
|
||||
},
|
||||
Configs: map[string]composetypes.ConfigObjConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no services in compose file",
|
||||
composeFiles: map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user