From 411286a705c9d27fd2ed7c404f8d84d0d5398e82 Mon Sep 17 00:00:00 2001
From: rcmadhankumar <madhankumar.chellamuthu@suse.com>
Date: Tue, 3 Feb 2026 15:59:49 +0530
Subject: [PATCH] CVE-2025-62725 fix: Enforce compose files from OCI artifact
 all get into the same target (cache) folder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

--

CVE-2025-62725: OCI compose artifacts can be used to escape the cache
directory and overwrite arbitrary files

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.

reference commit: https://github.com/docker/compose/commit/69bcb962bfb2ea53b41aa925333d356b577d6176

Fixes CVE-2025-62725
Fixes bsc#1252752
---
 pkg/remote/oci.go | 62 +++++++++++++++++++++++++++++++++++------------
 1 file changed, 47 insertions(+), 15 deletions(-)

diff --git a/pkg/remote/oci.go b/pkg/remote/oci.go
index 0f53a4c7..0c78b330 100644
--- a/pkg/remote/oci.go
+++ b/pkg/remote/oci.go
@@ -36,6 +36,32 @@ import (
 
 const OCI_REMOTE_ENABLED = "COMPOSE_EXPERIMENTAL_OCI_REMOTE"
 
+// 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)
@@ -104,7 +130,6 @@ func (g ociRemoteLoader) Load(ctx context.Context, path string) (string, error)
 		}
 
 		local = filepath.Join(cache, descriptor.Digest.Hex())
-		composeFile := filepath.Join(local, "compose.yaml")
 		if _, err = os.Stat(local); os.IsNotExist(err) {
 			var manifest v1.Manifest
 			err = json.Unmarshal(content, &manifest)
@@ -112,7 +137,7 @@ func (g ociRemoteLoader) Load(ctx context.Context, path string) (string, error)
 				return "", err
 			}
 
-			err2 := g.pullComposeFiles(ctx, local, composeFile, manifest, ref, resolver)
+			err2 := g.pullComposeFiles(ctx, local, manifest, ref, resolver)
 			if err2 != nil {
 				// we need to clean up the directory to be sure we won't let empty files present
 				_ = os.RemoveAll(local)
@@ -129,17 +154,12 @@ func (g ociRemoteLoader) Dir(path string) string {
 	return g.known[path]
 }
 
-func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, composeFile string, manifest v1.Manifest, ref reference.Named, resolver *imagetools.Resolver) error {
+func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, manifest v1.Manifest, ref reference.Named, resolver *imagetools.Resolver) error {
 	err := os.MkdirAll(local, 0o700)
 	if err != nil {
 		return err
 	}
 
-	f, err := os.Create(composeFile)
-	if err != nil {
-		return err
-	}
-	defer f.Close() //nolint:errcheck
 	if (manifest.ArtifactType != "" && manifest.ArtifactType != ocipush.ComposeProjectArtifactType) ||
 		(manifest.ArtifactType == "" && manifest.Config.MediaType != ocipush.ComposeEmptyConfigMediaType) {
 		return fmt.Errorf("%s is not a compose project OCI artifact, but %s", ref.String(), manifest.ArtifactType)
@@ -157,7 +177,7 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, com
 
 		switch layer.MediaType {
 		case ocipush.ComposeYAMLMediaType:
-			if err := writeComposeFile(layer, i, f, content); err != nil {
+			if err := writeComposeFile(layer, i, local, content); err != nil {
 				return err
 			}
 		case ocipush.ComposeEnvFileMediaType:
@@ -170,14 +190,25 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, com
 	return nil
 }
 
-func writeComposeFile(layer v1.Descriptor, i int, f *os.File, content []byte) error {
+func writeComposeFile(layer v1.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
 }
 
@@ -186,15 +217,16 @@ func writeEnvFile(layer v1.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{}
-- 
2.52.0

