diff --git a/pkg/domain/infra/abi/play.go b/pkg/domain/infra/abi/play.go index 2d9b780716..63a5f64f19 100644 --- a/pkg/domain/infra/abi/play.go +++ b/pkg/domain/infra/abi/play.go @@ -788,8 +788,7 @@ func (ic *ContainerEngine) playKubePod(ctx context.Context, podName string, podY defaultMode := v.DefaultMode // Create files and add data to the volume mountpoint based on the Items in the volume for k, v := range v.Items { - dataPath := filepath.Join(mountPoint, k) - f, err := os.Create(dataPath) + f, err := openPathSafely(mountPoint, k) if err != nil { return nil, nil, fmt.Errorf("cannot create file %q at volume mountpoint %q: %w", k, mountPoint, err) } @@ -799,7 +798,7 @@ func (ic *ContainerEngine) playKubePod(ctx context.Context, podName string, podY return nil, nil, err } // Set file permissions - if err := os.Chmod(f.Name(), os.FileMode(defaultMode)); err != nil { + if err := f.Chmod(os.FileMode(defaultMode)); err != nil { return nil, nil, err } } diff --git a/pkg/domain/infra/abi/play_linux.go b/pkg/domain/infra/abi/play_linux.go new file mode 100644 index 0000000000..a0f9811516 --- /dev/null +++ b/pkg/domain/infra/abi/play_linux.go @@ -0,0 +1,18 @@ +//go:build !remote + +package abi + +import ( + "os" + + securejoin "github.com/cyphar/filepath-securejoin" +) + +// openSymlinkPath opens the path under root using securejoin.OpenatInRoot(). +func openSymlinkPath(root *os.File, unsafePath string, flags int) (*os.File, error) { + file, err := securejoin.OpenatInRoot(root, unsafePath) + if err != nil { + return nil, err + } + return securejoin.Reopen(file, flags) +} diff --git a/pkg/domain/infra/abi/play_unsupported.go b/pkg/domain/infra/abi/play_unsupported.go new file mode 100644 index 0000000000..3ecbae7cc1 --- /dev/null +++ b/pkg/domain/infra/abi/play_unsupported.go @@ -0,0 +1,13 @@ +//go:build !linux && !remote + +package abi + +import ( + "errors" + "os" +) + +// openSymlinkPath is not supported on this platform. +func openSymlinkPath(root *os.File, unsafePath string, flags int) (*os.File, error) { + return nil, errors.New("cannot safely open symlink on this platform") +} diff --git a/pkg/domain/infra/abi/play_utils.go b/pkg/domain/infra/abi/play_utils.go index 7285d9c9b9..217b656997 100644 --- a/pkg/domain/infra/abi/play_utils.go +++ b/pkg/domain/infra/abi/play_utils.go @@ -2,7 +2,14 @@ package abi -import "github.com/containers/podman/v5/libpod/define" +import ( + "fmt" + "os" + "strings" + + "github.com/containers/podman/v5/libpod/define" + "golang.org/x/sys/unix" +) // getSdNotifyMode returns the `sdNotifyAnnotation/$name` for the specified // name. If name is empty, it'll only look for `sdNotifyAnnotation`. @@ -16,3 +23,33 @@ func getSdNotifyMode(annotations map[string]string, name string) (string, error) } return mode, define.ValidateSdNotifyMode(mode) } + +// openPathSafely opens the given name under the trusted root path, the unsafeName +// must be a single path component and not contain "/". +// The resulting path will be opened or created if it does not exists. +// Following of symlink is done within staying under root, escapes outsides +// of root are not allowed and prevent. +// +// This custom function is needed because securejoin.SecureJoin() is not race safe +// and the volume might be mounted in another container that could swap in a symlink +// after the function ahs run. securejoin.OpenInRoot() doesn't work either because +// it cannot create files and doesn't work on freebsd. +func openPathSafely(root, unsafeName string) (*os.File, error) { + if strings.Contains(unsafeName, "/") { + return nil, fmt.Errorf("name %q must not contain path separator", unsafeName) + } + fdDir, err := os.OpenFile(root, unix.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer fdDir.Close() + flags := unix.O_CREAT | unix.O_WRONLY | unix.O_TRUNC | unix.O_CLOEXEC + fd, err := unix.Openat(int(fdDir.Fd()), unsafeName, flags|unix.O_NOFOLLOW, 0o644) + if err == nil { + return os.NewFile(uintptr(fd), unsafeName), nil + } + if err == unix.ELOOP { + return openSymlinkPath(fdDir, unsafeName, flags) + } + return nil, &os.PathError{Op: "openat", Path: unsafeName, Err: err} +} diff --git a/test/e2e/play_kube_test.go b/test/e2e/play_kube_test.go index a739618249..8fb341ec28 100644 --- a/test/e2e/play_kube_test.go +++ b/test/e2e/play_kube_test.go @@ -2157,7 +2157,7 @@ func getPersistentVolumeClaimVolume(vName string) *Volume { // getConfigMap returns a new ConfigMap Volume given the name and items // of the ConfigMap. -func getConfigMapVolume(vName string, items []map[string]string, optional bool, defaultMode *int32) *Volume { //nolint:unparam +func getConfigMapVolume(vName string, items []map[string]string, optional bool, defaultMode *int32) *Volume { vol := &Volume{ VolumeType: "ConfigMap", Name: defaultVolName, @@ -6182,4 +6182,32 @@ spec: Expect(execArr[len(execArr)-1]).To(Not(ContainSubstring(arr[len(arr)-1]))) }) + It("CVE-2025-9566 regression test - ConfigMap mount", func() { + testfile := filepath.Join(podmanTest.TempDir, "testfile") + volumeName := "cm-vol" + cm := getConfigMap(withConfigMapName(volumeName), withConfigMapData("foo", "content1")) + cmYaml, err := getKubeYaml("configmap", cm) + Expect(err).ToNot(HaveOccurred()) + + ctrName := "ctr1" + podName := "pod1" + // create a symlink at the volume mount location so we can make sure we don't resolve that to the host location. + ctr := getCtr(withName(ctrName), withVolumeMount("/test", "", false), withImage(CITEST_IMAGE), withCmd([]string{"sh", "-c", "ln -sf " + testfile + " /test/foo"})) + pod := getPod(withPodName(podName), withVolume(getConfigMapVolume(volumeName, nil, false, nil)), withCtr(ctr)) + podYaml, err := getKubeYaml("pod", pod) + Expect(err).ToNot(HaveOccurred()) + yamls := []string{cmYaml, podYaml} + err = generateMultiDocKubeYaml(yamls, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + podmanTest.PodmanExitCleanly("kube", "play", kubeYaml) + // wait for the container to finish to ensure the symlink was created + podmanTest.PodmanExitCleanly("wait", podName+"-"+ctrName) + podmanTest.PodmanExitCleanly("kube", "down", kubeYaml) + kube := podmanTest.Podman([]string{"kube", "play", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).To(ExitWithError(125, `cannot create file "foo" at volume mountpoint`)) + + Expect(testfile).ToNot(BeAnExistingFile(), "file should never be created on the host") + }) })