VYPR
High severityOSV Advisory· Published Oct 27, 2025· Updated Apr 15, 2026

CVE-2025-62725

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.

PackageAffected versionsPatched versions
github.com/docker/compose/v2Go
>= 2.34.0, < 2.40.22.40.2

Affected products

1

Patches

1
69bcb962bfb2

Enforce compose files from OCI artifact all get into the same target (cache) folder

https://github.com/docker/composeGuillaume LoursOct 21, 2025via ghsa
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

4

News mentions

0

No linked articles in our index yet.