Description
We've found a path confinement issue in docker cli. Documentation is unclear if path confinement is expected, but I did want to raise as a security issue first.
Also I’ve been watching the push to harden docker including the new docker sandboxes so I am inclined to think that if this is not currently a vulnerability it will be considered so in the future.
Specifically, when docker stack deploy processes a compose file, secrets.file and configs.file paths are resolved relative to the project directory using filepath.Join, but the resolved path is not checked for confinement within that directory. A value like file: "../../etc/shadow" cleanly resolves to /etc/shadow, and the CLI reads the file and uploads its contents to the Swarm secret store. Absolute paths work too. This is handled by loadFileObjectConfig() in cli/compose/loader/loader.go:672, which calls absPath(), and the result is passed directly to os.ReadFile() in cli/compose/convert/compose.go:185.
We've confirmed this end-to-end against the current docker/cli source using a Go test that exercises the actual loader and converter. The canary file outside the project directory is read and packaged as a Swarm secret.
We recognize this is consistent with how Compose resolves relative paths today, and the documentation describes these paths as "relative to" the project directory without promising confinement. We're not framing this as a contract violation. However, secret and config file reads are unique among Compose path features because the file content leaves the operator's host machine. It's uploaded to the Swarm secret store and distributed to service containers. This is a data exfiltration path that doesn't exist with bind mounts (which give the container access to a host path but don't copy content off the host) or env_file (which stays local to the CLI process). An operator deploying a compose file from a shared repo, PR, or quickstart guide may not expect that a secrets.file directive causes reads outside the project tree and transmits the result to Swarm.
Given Docker's broader investment in hardened defaults like microVMs, build attestations, and content trust, we think confining secrets.file and configs.file resolution to the project directory would be a low-cost improvement that aligns with that direction. The docker/compose project added similar path confinement in the git loader (PR #13331) for content from untrusted sources; the same principle applies when the compose file itself is the untrusted input. The fix is approximately five lines at a single call site in loadFileObjectConfig() — a filepath.Clean plus a strings.HasPrefix check after the existing absPath call. It would not affect volume bind mounts or env_file, which have different documented semantics. We have a working POC and suggested patch available if helpful.
Steps to reproduce
- Create a canary file outside the project directory
echo “MY SECRET" > /tmp/secret.txt```
Change /tmp/secret.txt to whatever works for you...
2. Create a compose file that traverses out of the project dir
Contents of the file if stored as /tmp/poc/test.yml (but you can change the file: to whatever makes sense for your path:
version: "3.8"
secrets:
leaked:
file: "../secret.txt"
services:
app:
image: alpine
secrets: [leaked]
command: ["cat", "/run/secrets/leaked"]
3. Deploy — the CLI reads /tmp/poc/secret.txt and uploads it as a Swarm secret
docker swarm init # if not already in swarm mode
docker stack deploy -c /tmp/poc/test.yml poc-test
# 4. Verify the secret was created with the canary content
docker service logs poc-test_app
# Output: MY SECRET
# Cleanup
docker stack rm poc-test
docker swarm leave --force
The traversal also works with absolute paths (file: "/etc/passwd") and deeper relative paths (file: "../../../../home/user/.ssh/id_rsa"). A Go unit test exercising the actual loader and converter code is available on request.
Here’s the proof:
PastedGraphic-1.png
Suggested fix
Add a confinement check in loadFileObjectConfig after the absPath call.
This is the only call site that needs it — env_file and volume bind mounts
have different documented semantics.
// cli/compose/loader/loader.go — inside loadFileObjectConfig, after line 672
default:
obj.File = absPath(details.WorkingDir, obj.File)
// Verify resolved path stays within the project directory
cleanBase := filepath.Clean(details.WorkingDir) + string(filepath.Separator)
cleanFile := filepath.Clean(obj.File)
if !strings.HasPrefix(cleanFile+string(filepath.Separator), cleanBase) {
return obj, fmt.Errorf(
"%s %s: file path %q resolves to %q, which is outside the project directory",
objType, name, obj.File, cleanFile,
)
}
This is ~5 lines at a single call site. It covers both secrets and configs
since both go through loadFileObjectConfig.
Description
We've found a path confinement issue in docker cli. Documentation is unclear if path confinement is expected, but I did want to raise as a security issue first.
Also I’ve been watching the push to harden docker including the new docker sandboxes so I am inclined to think that if this is not currently a vulnerability it will be considered so in the future.
Specifically, when
docker stack deployprocesses a compose file,secrets.fileandconfigs.filepaths are resolved relative to the project directory usingfilepath.Join, but the resolved path is not checked for confinement within that directory. A value likefile: "../../etc/shadow"cleanly resolves to/etc/shadow, and the CLI reads the file and uploads its contents to the Swarm secret store. Absolute paths work too. This is handled byloadFileObjectConfig()incli/compose/loader/loader.go:672, which callsabsPath(), and the result is passed directly toos.ReadFile()incli/compose/convert/compose.go:185.We've confirmed this end-to-end against the current docker/cli source using a Go test that exercises the actual loader and converter. The canary file outside the project directory is read and packaged as a Swarm secret.
We recognize this is consistent with how Compose resolves relative paths today, and the documentation describes these paths as "relative to" the project directory without promising confinement. We're not framing this as a contract violation. However, secret and config file reads are unique among Compose path features because the file content leaves the operator's host machine. It's uploaded to the Swarm secret store and distributed to service containers. This is a data exfiltration path that doesn't exist with bind mounts (which give the container access to a host path but don't copy content off the host) or
env_file(which stays local to the CLI process). An operator deploying a compose file from a shared repo, PR, or quickstart guide may not expect that asecrets.filedirective causes reads outside the project tree and transmits the result to Swarm.Given Docker's broader investment in hardened defaults like microVMs, build attestations, and content trust, we think confining
secrets.fileandconfigs.fileresolution to the project directory would be a low-cost improvement that aligns with that direction. The docker/compose project added similar path confinement in the git loader (PR #13331) for content from untrusted sources; the same principle applies when the compose file itself is the untrusted input. The fix is approximately five lines at a single call site inloadFileObjectConfig()— afilepath.Cleanplus astrings.HasPrefixcheck after the existingabsPathcall. It would not affect volume bind mounts orenv_file, which have different documented semantics. We have a working POC and suggested patch available if helpful.Steps to reproduce
docker swarm init # if not already in swarm mode
docker stack deploy -c /tmp/poc/test.yml poc-test
The traversal also works with absolute paths (
file: "/etc/passwd") and deeper relative paths (file: "../../../../home/user/.ssh/id_rsa"). A Go unit test exercising the actual loader and converter code is available on request.Here’s the proof:
PastedGraphic-1.png
Suggested fix
Add a confinement check in
loadFileObjectConfigafter theabsPathcall.This is the only call site that needs it —
env_fileand volume bind mountshave different documented semantics.
This is ~5 lines at a single call site. It covers both secrets and configs
since both go through
loadFileObjectConfig.