CVE-2025-62725
Description
Docker Compose trusts the path information embedded in remote OCI compose artifacts. When a layer includes the annotations com.docker.compose.extends or com.docker.compose.envfile, Compose joins the attacker‑supplied value from com.docker.compose.file/com.docker.compose.envfile with its local cache directory and writes the file there. This affects any platform or workflow that resolves remote OCI compose artifacts, Docker Desktop, standalone Compose binaries on Linux, CI/CD runners, cloud dev environments is affected. An attacker can escape the cache directory and overwrite arbitrary files on the machine running docker compose, even if the user only runs read‑only commands such as docker compose config or docker compose ps. This issue is fixed in v2.40.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/docker/compose/v2Go | >= 2.34.0, < 2.40.2 | 2.40.2 |
Affected products
1Patches
169bcb962bfb2Enforce compose files from OCI artifact all get into the same target (cache) folder
2 files changed · +170 −21
pkg/remote/oci.go+45 −21 modified@@ -39,6 +39,32 @@ const ( OciPrefix = "oci://" ) +// validatePathInBase ensures a file path is contained within the base directory, +// as OCI artifacts resources must all live within the same folder. +func validatePathInBase(base, unsafePath string) error { + // Reject paths with path separators regardless of OS + if strings.ContainsAny(unsafePath, "\\/") { + return fmt.Errorf("invalid OCI artifact") + } + + // Join the base with the untrusted path + targetPath := filepath.Join(base, unsafePath) + + // Get the directory of the target path + targetDir := filepath.Dir(targetPath) + + // Clean both paths to resolve any .. or . components + cleanBase := filepath.Clean(base) + cleanTargetDir := filepath.Clean(targetDir) + + // Check if the target directory is the same as base directory + if cleanTargetDir != cleanBase { + return fmt.Errorf("invalid OCI artifact") + } + + return nil +} + func ociRemoteLoaderEnabled() (bool, error) { if v := os.Getenv(OCI_REMOTE_ENABLED); v != "" { enabled, err := strconv.ParseBool(v) @@ -158,12 +184,6 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man if err != nil { return err } - composeFile := filepath.Join(local, "compose.yaml") - f, err := os.Create(composeFile) - if err != nil { - return err - } - defer f.Close() //nolint:errcheck if (manifest.ArtifactType != "" && manifest.ArtifactType != oci.ComposeProjectArtifactType) || (manifest.ArtifactType == "" && manifest.Config.MediaType != oci.ComposeEmptyConfigMediaType) { return fmt.Errorf("%s is not a compose project OCI artifact, but %s", ref.String(), manifest.ArtifactType) @@ -182,15 +202,7 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man switch layer.MediaType { case oci.ComposeYAMLMediaType: - target := f - _, extends := layer.Annotations["com.docker.compose.extends"] - if extends { - target, err = os.Create(filepath.Join(local, layer.Annotations["com.docker.compose.file"])) - if err != nil { - return err - } - } - if err := writeComposeFile(layer, i, target, content); err != nil { + if err := writeComposeFile(layer, i, local, content); err != nil { return err } case oci.ComposeEnvFileMediaType: @@ -203,14 +215,25 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man return nil } -func writeComposeFile(layer spec.Descriptor, i int, f *os.File, content []byte) error { +func writeComposeFile(layer spec.Descriptor, i int, local string, content []byte) error { + file := "compose.yaml" + if extends, ok := layer.Annotations["com.docker.compose.extends"]; ok { + if err := validatePathInBase(local, extends); err != nil { + return err + } + } + f, err := os.Create(filepath.Join(local, file)) + if err != nil { + return err + } + defer func() { _ = f.Close() }() if _, ok := layer.Annotations["com.docker.compose.file"]; i > 0 && ok { _, err := f.Write([]byte("\n---\n")) if err != nil { return err } } - _, err := f.Write(content) + _, err = f.Write(content) return err } @@ -219,15 +242,16 @@ func writeEnvFile(layer spec.Descriptor, local string, content []byte) error { if !ok { return fmt.Errorf("missing annotation com.docker.compose.envfile in layer %q", layer.Digest) } - otherFile, err := os.Create(filepath.Join(local, envfilePath)) - if err != nil { + if err := validatePathInBase(local, envfilePath); err != nil { return err } - _, err = otherFile.Write(content) + otherFile, err := os.Create(filepath.Join(local, envfilePath)) if err != nil { return err } - return nil + defer func() { _ = otherFile.Close() }() + _, err = otherFile.Write(content) + return err } var _ loader.ResourceLoader = ociRemoteLoader{}
pkg/remote/oci_test.go+125 −0 added@@ -0,0 +1,125 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package remote + +import ( + "path/filepath" + "testing" +) + +func TestValidatePathInBase(t *testing.T) { + base := "/tmp/cache/compose" + + tests := []struct { + name string + unsafePath string + wantErr bool + }{ + { + name: "valid simple filename", + unsafePath: "compose.yaml", + wantErr: false, + }, + { + name: "valid hashed filename", + unsafePath: "f8f9ede3d201ec37d5a5e3a77bbadab79af26035e53135e19571f50d541d390c.yaml", + wantErr: false, + }, + { + name: "valid env file", + unsafePath: ".env", + wantErr: false, + }, + { + name: "valid env file with suffix", + unsafePath: ".env.prod", + wantErr: false, + }, + { + name: "unix path traversal", + unsafePath: "../../../etc/passwd", + wantErr: true, + }, + { + name: "windows path traversal", + unsafePath: "..\\..\\..\\windows\\system32\\config\\sam", + wantErr: true, + }, + { + name: "subdirectory unix", + unsafePath: "config/base.yaml", + wantErr: true, + }, + { + name: "subdirectory windows", + unsafePath: "config\\base.yaml", + wantErr: true, + }, + { + name: "absolute unix path", + unsafePath: "/etc/passwd", + wantErr: true, + }, + { + name: "absolute windows path", + unsafePath: "C:\\windows\\system32\\config\\sam", + wantErr: true, + }, + { + name: "parent reference only", + unsafePath: "..", + wantErr: true, + }, + { + name: "current directory reference", + unsafePath: "./file.yaml", + wantErr: false, // ./ resolves to base dir + }, + { + name: "mixed separators", + unsafePath: "config/sub\\file.yaml", + wantErr: true, + }, + { + name: "filename with spaces", + unsafePath: "my file.yaml", + wantErr: false, + }, + { + name: "filename with special chars", + unsafePath: "file-name_v1.2.3.yaml", + wantErr: false, + }, + { + name: "single parent then back", + unsafePath: "../compose/file.yaml", + wantErr: false, // Resolves back to base dir, which is fine + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePathInBase(base, tt.unsafePath) + if (err != nil) != tt.wantErr { + targetPath := filepath.Join(base, tt.unsafePath) + targetDir := filepath.Dir(targetPath) + t.Errorf("validatePathInBase(%q, %q) error = %v, wantErr %v\ntargetDir=%q base=%q", + base, tt.unsafePath, err, tt.wantErr, targetDir, base) + } + }) + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.