docker / docker/cli

Path confinement for `secrets.file` and `configs.file` in stack deploy

Open Beginner friendly
#6,919 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

kind/feature status/0-triage
Dominant language
Go
Stars
6.1k
Forks
2.2k
Avg merge
1d 15h
Merged PRs (30d)
43

Description

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

  1. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in cli/compose/loader/loader.go at loadFileObjectConfig(), then trace the resolved path into os.ReadFile() in cli/compose/convert/compose.go. Reproduce the traversal with docker stack deploy and exercise both secrets.file and configs.file. Done means paths outside the project directory, including absolute paths, are rejected while in-project files still deploy normally.

Written by the indexing model from the issue text.

Assessment

Tech stack
docker, go
Domain
cli, security
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.