fix(swarm): swarm config + secrets path traversal [BE-13200] (#3179)

This commit is contained in:
Devon Steenberg
2026-07-20 11:10:27 +12:00
committed by GitHub
parent e465128e29
commit e5b306eb4f
2 changed files with 241 additions and 1 deletions
+34 -1
View File
@@ -715,7 +715,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,
@@ -746,6 +746,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,
@@ -780,6 +789,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(
+207
View File
@@ -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{