diff --git a/internal/pathrs/mkdirall.go b/internal/pathrs/mkdirall.go new file mode 100644 index 00000000000..3a896f48415 --- /dev/null +++ b/internal/pathrs/mkdirall.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +/* + * Copyright (C) 2024-2025 Aleksa Sarai + * Copyright (C) 2024-2025 SUSE LLC + * + * 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 pathrs + +import ( + "fmt" + "os" + "path/filepath" +) + +// MkdirAllParentInRoot is like [MkdirAllInRoot] except that it only creates +// the parent directory of the target path, returning the trailing component so +// the caller has more flexibility around constructing the final inode. +// +// Callers need to be very careful operating on the trailing path, as trivial +// mistakes like following symlinks can cause security bugs. Most people +// should probably just use [MkdirAllInRoot] or [CreateInRoot]. +func MkdirAllParentInRoot(root, unsafePath string, mode os.FileMode) (*os.File, string, error) { + // MkdirAllInRoot also does hallucinateUnsafePath, but we need to do it + // here first because when we split unsafePath into (dir, file) components + // we want to be doing so with the hallucinated path (so that trailing + // dangling symlinks are treated correctly). + unsafePath, err := hallucinateUnsafePath(root, unsafePath) + if err != nil { + return nil, "", fmt.Errorf("failed to construct hallucinated target path: %w", err) + } + + dirPath, filename := filepath.Split(unsafePath) + if filepath.Join("/", filename) == "/" { + return nil, "", fmt.Errorf("create parent dir in root subpath %q has bad trailing component %q", unsafePath, filename) + } + + dirFd, err := MkdirAllInRoot(root, dirPath, mode) + return dirFd, filename, err +} diff --git a/internal/pathrs/mkdirall_pathrslite.go b/internal/pathrs/mkdirall_pathrslite.go index a9a0157c681..c2578e051fe 100644 --- a/internal/pathrs/mkdirall_pathrslite.go +++ b/internal/pathrs/mkdirall_pathrslite.go @@ -21,14 +21,13 @@ package pathrs import ( "fmt" "os" - "path/filepath" "github.com/cyphar/filepath-securejoin/pathrs-lite" "github.com/sirupsen/logrus" "golang.org/x/sys/unix" ) -// MkdirAllInRootOpen attempts to make +// MkdirAllInRoot attempts to make // // path, _ := securejoin.SecureJoin(root, unsafePath) // os.MkdirAll(path, mode) @@ -49,19 +48,10 @@ import ( // handling if unsafePath has already been scoped within the rootfs (this is // needed for a lot of runc callers and fixing this would require reworking a // lot of path logic). -func MkdirAllInRootOpen(root, unsafePath string, mode os.FileMode) (*os.File, error) { - // If the path is already "within" the root, get the path relative to the - // root and use that as the unsafe path. This is necessary because a lot of - // MkdirAllInRootOpen callers have already done SecureJoin, and refactoring - // all of them to stop using these SecureJoin'd paths would require a fair - // amount of work. - // TODO(cyphar): Do the refactor to libpathrs once it's ready. - if IsLexicallyInRoot(root, unsafePath) { - subPath, err := filepath.Rel(root, unsafePath) - if err != nil { - return nil, err - } - unsafePath = subPath +func MkdirAllInRoot(root, unsafePath string, mode os.FileMode) (*os.File, error) { + unsafePath, err := hallucinateUnsafePath(root, unsafePath) + if err != nil { + return nil, fmt.Errorf("failed to construct hallucinated target path: %w", err) } // Check for any silly mode bits. @@ -87,13 +77,3 @@ func MkdirAllInRootOpen(root, unsafePath string, mode os.FileMode) (*os.File, er return pathrs.MkdirAllHandle(rootDir, unsafePath, mode) }) } - -// MkdirAllInRoot is a wrapper around MkdirAllInRootOpen which closes the -// returned handle, for callers that don't need to use it. -func MkdirAllInRoot(root, unsafePath string, mode os.FileMode) error { - f, err := MkdirAllInRootOpen(root, unsafePath, mode) - if err == nil { - _ = f.Close() - } - return err -} diff --git a/internal/pathrs/path.go b/internal/pathrs/path.go index 1ee7c795d5b..77be9892411 100644 --- a/internal/pathrs/path.go +++ b/internal/pathrs/path.go @@ -19,7 +19,11 @@ package pathrs import ( + "os" + "path/filepath" "strings" + + securejoin "github.com/cyphar/filepath-securejoin" ) // IsLexicallyInRoot is shorthand for strings.HasPrefix(path+"/", root+"/"), @@ -32,3 +36,81 @@ func IsLexicallyInRoot(root, path string) bool { path = strings.TrimRight(path, "/") return strings.HasPrefix(path+"/", root+"/") } + +// LexicallyCleanPath makes a path safe for use with filepath.Join. This is +// done by not only cleaning the path, but also (if the path is relative) +// adding a leading '/' and cleaning it (then removing the leading '/'). This +// ensures that a path resulting from prepending another path will always +// resolve to lexically be a subdirectory of the prefixed path. This is all +// done lexically, so paths that include symlinks won't be safe as a result of +// using CleanPath. +func LexicallyCleanPath(path string) string { + // Deal with empty strings nicely. + if path == "" { + return "" + } + + // Ensure that all paths are cleaned (especially problematic ones like + // "/../../../../../" which can cause lots of issues). + + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + + // If the path isn't absolute, we need to do more processing to fix paths + // such as "../../../..//some/path". We also shouldn't convert absolute + // paths to relative ones. + path = filepath.Clean(string(os.PathSeparator) + path) + // This can't fail, as (by definition) all paths are relative to root. + path, _ = filepath.Rel(string(os.PathSeparator), path) + + return path +} + +// LexicallyStripRoot returns the passed path, stripping the root path if it +// was (lexicially) inside it. Note that both passed paths will always be +// treated as absolute, and the returned path will also always be absolute. In +// addition, the paths are cleaned before stripping the root. +func LexicallyStripRoot(root, path string) string { + // Make the paths clean and absolute. + root, path = LexicallyCleanPath("/"+root), LexicallyCleanPath("/"+path) + switch { + case path == root: + path = "/" + case root == "/": + // do nothing + default: + path = strings.TrimPrefix(path, root+"/") + } + return LexicallyCleanPath("/" + path) +} + +// hallucinateUnsafePath creates a new unsafePath which has all symlinks +// (including dangling symlinks) fully resolved and any non-existent components +// treated as though they are real. This is effectively just a wrapper around +// [securejoin.SecureJoin] that strips the root. This path *IS NOT* safe to use +// as-is, you *MUST* operate on the returned path with pathrs-lite. +// +// The reason for this methods is that in previous runc versions, we would +// tolerate nonsense paths with dangling symlinks as path components. +// pathrs-lite does not support this, so instead we have to emulate this +// behaviour by doing SecureJoin *purely to get a semi-reasonable path to use* +// and then we use pathrs-lite to operate on the path safely. +// +// It would be quite difficult to emulate this in a race-free way in +// pathrs-lite, so instead we use [securejoin.SecureJoin] to simply produce a +// new candidate path for operations like [MkdirAllInRoot] so they can then +// operate on the new unsafePath as if it was what the user requested. +// +// If unsafePath is already lexically inside root, it is stripped before +// re-resolving it (this is done to ensure compatibility with legacy callers +// within runc that call SecureJoin before calling into pathrs). +func hallucinateUnsafePath(root, unsafePath string) (string, error) { + unsafePath = LexicallyStripRoot(root, unsafePath) + weirdPath, err := securejoin.SecureJoin(root, unsafePath) + if err != nil { + return "", err + } + unsafePath = LexicallyStripRoot(root, weirdPath) + return unsafePath, nil +} diff --git a/internal/pathrs/path_test.go b/internal/pathrs/path_test.go index 19d577fba3b..b7fdde6b94a 100644 --- a/internal/pathrs/path_test.go +++ b/internal/pathrs/path_test.go @@ -51,3 +51,70 @@ func TestIsLexicallyInRoot(t *testing.T) { }) } } + +func TestLexicallyCleanPath(t *testing.T) { + path := LexicallyCleanPath("") + if path != "" { + t.Errorf("expected to receive empty string and received %s", path) + } + + path = LexicallyCleanPath("rootfs") + if path != "rootfs" { + t.Errorf("expected to receive 'rootfs' and received %s", path) + } + + path = LexicallyCleanPath("../../../var") + if path != "var" { + t.Errorf("expected to receive 'var' and received %s", path) + } + + path = LexicallyCleanPath("/../../../var") + if path != "/var" { + t.Errorf("expected to receive '/var' and received %s", path) + } + + path = LexicallyCleanPath("/foo/bar/") + if path != "/foo/bar" { + t.Errorf("expected to receive '/foo/bar' and received %s", path) + } + + path = LexicallyCleanPath("/foo/bar/../") + if path != "/foo" { + t.Errorf("expected to receive '/foo' and received %s", path) + } +} + +func TestLexicallyStripRoot(t *testing.T) { + for _, test := range []struct { + root, path, out string + }{ + // Works with multiple components. + {"/a/b", "/a/b/c", "/c"}, + {"/hello/world", "/hello/world/the/quick-brown/fox", "/the/quick-brown/fox"}, + // '/' must be a no-op. + {"/", "/a/b/c", "/a/b/c"}, + // Must be the correct order. + {"/a/b", "/a/c/b", "/a/c/b"}, + // Must be at start. + {"/abc/def", "/foo/abc/def/bar", "/foo/abc/def/bar"}, + // Must be a lexical parent. + {"/foo/bar", "/foo/barSAMECOMPONENT", "/foo/barSAMECOMPONENT"}, + // Must only strip the root once. + {"/foo/bar", "/foo/bar/foo/bar/baz", "/foo/bar/baz"}, + // Deal with .. in a fairly sane way. + {"/foo/bar", "/foo/bar/../baz", "/foo/baz"}, + {"/foo/bar", "../../../../../../foo/bar/baz", "/baz"}, + {"/foo/bar", "/../../../../../../foo/bar/baz", "/baz"}, + {"/foo/bar/../baz", "/foo/baz/bar", "/bar"}, + {"/foo/bar/../baz", "/foo/baz/../bar/../baz/./foo", "/foo"}, + // All paths are made absolute before stripping. + {"foo/bar", "/foo/bar/baz/bee", "/baz/bee"}, + {"/foo/bar", "foo/bar/baz/beef", "/baz/beef"}, + {"foo/bar", "foo/bar/baz/beets", "/baz/beets"}, + } { + got := LexicallyStripRoot(test.root, test.path) + if got != test.out { + t.Errorf("LexicallyStripRoot(%q, %q) -- got %q, expected %q", test.root, test.path, got, test.out) + } + } +} diff --git a/internal/pathrs/root_pathrslite.go b/internal/pathrs/root_pathrslite.go index 9e69e8d6c54..51db77440d7 100644 --- a/internal/pathrs/root_pathrslite.go +++ b/internal/pathrs/root_pathrslite.go @@ -19,9 +19,7 @@ package pathrs import ( - "fmt" "os" - "path/filepath" "github.com/cyphar/filepath-securejoin/pathrs-lite" "golang.org/x/sys/unix" @@ -50,12 +48,7 @@ func OpenInRoot(root, subpath string, flags int) (*os.File, error) { // include it in the passed flags. The fileMode argument uses unix.* mode bits, // *not* os.FileMode. func CreateInRoot(root, subpath string, flags int, fileMode uint32) (*os.File, error) { - dir, filename := filepath.Split(subpath) - if filepath.Join("/", filename) == "/" { - return nil, fmt.Errorf("create in root subpath %q has bad trailing component %q", subpath, filename) - } - - dirFd, err := MkdirAllInRootOpen(root, dir, 0o755) + dirFd, filename, err := MkdirAllParentInRoot(root, subpath, 0o755) if err != nil { return nil, err } diff --git a/libcontainer/apparmor/apparmor_linux.go b/libcontainer/apparmor/apparmor_linux.go index 2cde88bae7d..9bb4fb2cd2d 100644 --- a/libcontainer/apparmor/apparmor_linux.go +++ b/libcontainer/apparmor/apparmor_linux.go @@ -9,7 +9,6 @@ import ( "golang.org/x/sys/unix" "github.com/opencontainers/runc/internal/pathrs" - "github.com/opencontainers/runc/libcontainer/utils" ) var ( @@ -29,7 +28,7 @@ func isEnabled() bool { } func setProcAttr(attr, value string) error { - attr = utils.CleanPath(attr) + attr = pathrs.LexicallyCleanPath(attr) attrSubPath := "attr/apparmor/" + attr if _, err := os.Stat("/proc/self/" + attrSubPath); errors.Is(err, os.ErrNotExist) { // fall back to the old convention diff --git a/libcontainer/criu_linux.go b/libcontainer/criu_linux.go index f6f5c906fd6..54dfdfd9cc1 100644 --- a/libcontainer/criu_linux.go +++ b/libcontainer/criu_linux.go @@ -24,6 +24,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/opencontainers/cgroups" + "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/libcontainer/configs" "github.com/opencontainers/runc/libcontainer/utils" ) @@ -542,16 +543,22 @@ func (c *Container) prepareCriuRestoreMounts(mounts []*configs.Mount) error { umounts := []string{} defer func() { for _, u := range umounts { - _ = utils.WithProcfd(c.config.Rootfs, u, func(procfd string) error { - if e := unix.Unmount(procfd, unix.MNT_DETACH); e != nil { - if e != unix.EINVAL { + mntFile, err := pathrs.OpenInRoot(c.config.Rootfs, u, unix.O_PATH) + if err != nil { + logrus.Warnf("Error during cleanup unmounting %s: open handle: %v", u, err) + continue + } + _ = utils.WithProcfdFile(mntFile, func(procfd string) error { + if err := unix.Unmount(procfd, unix.MNT_DETACH); err != nil { + if err != unix.EINVAL { // Ignore EINVAL as it means 'target is not a mount point.' // It probably has already been unmounted. - logrus.Warnf("Error during cleanup unmounting of %s (%s): %v", procfd, u, e) + logrus.Warnf("Error during cleanup unmounting of %s (%s): %v", procfd, u, err) } } return nil }) + _ = mntFile.Close() } }() // Now go through all mounts and create the required mountpoints. diff --git a/libcontainer/factory_linux.go b/libcontainer/factory_linux.go index b799e4a98a9..70d935c0d38 100644 --- a/libcontainer/factory_linux.go +++ b/libcontainer/factory_linux.go @@ -11,10 +11,10 @@ import ( "github.com/opencontainers/cgroups" "github.com/opencontainers/cgroups/manager" + "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/libcontainer/configs" "github.com/opencontainers/runc/libcontainer/configs/validate" "github.com/opencontainers/runc/libcontainer/intelrdt" - "github.com/opencontainers/runc/libcontainer/utils" ) const ( @@ -211,7 +211,7 @@ func validateID(id string) error { } - if string(os.PathSeparator)+id != utils.CleanPath(string(os.PathSeparator)+id) { + if string(os.PathSeparator)+id != pathrs.LexicallyCleanPath(string(os.PathSeparator)+id) { return ErrInvalidID } diff --git a/libcontainer/rootfs_linux.go b/libcontainer/rootfs_linux.go index 9f2af0a212d..040ef3f6067 100644 --- a/libcontainer/rootfs_linux.go +++ b/libcontainer/rootfs_linux.go @@ -12,7 +12,6 @@ import ( "syscall" "time" - securejoin "github.com/cyphar/filepath-securejoin" "github.com/cyphar/filepath-securejoin/pathrs-lite/procfs" "github.com/moby/sys/mountinfo" "github.com/moby/sys/userns" @@ -91,7 +90,7 @@ func (m mountEntry) srcStatfs() (*unix.Statfs_t, error) { // needsSetupDev returns true if /dev needs to be set up. func needsSetupDev(config *configs.Config) bool { for _, m := range config.Mounts { - if m.Device == "bind" && utils.CleanPath(m.Destination) == "/dev" { + if m.Device == "bind" && pathrs.LexicallyCleanPath(m.Destination) == "/dev" { return false } } @@ -257,7 +256,7 @@ func finalizeRootfs(config *configs.Config) (err error) { if m.Flags&unix.MS_RDONLY != unix.MS_RDONLY { continue } - if m.Device == "tmpfs" || utils.CleanPath(m.Destination) == "/dev" { + if m.Device == "tmpfs" || pathrs.LexicallyCleanPath(m.Destination) == "/dev" { if err := remountReadonly(m); err != nil { return err } @@ -329,12 +328,15 @@ func mountCgroupV1(m mountEntry, c *mountConfig) error { // We just created the tmpfs, and so we can just use filepath.Join // here (not to mention we want to make sure we create the path // inside the tmpfs, so we don't want to resolve symlinks). + // TODO: Why not just use b.Destination (c.root is the root here)? subsystemPath := filepath.Join(c.root, b.Destination) subsystemName := filepath.Base(b.Destination) - if err := pathrs.MkdirAllInRoot(c.root, subsystemPath, 0o755); err != nil { + subsystemDir, err := pathrs.MkdirAllInRoot(c.root, subsystemPath, 0o755) + if err != nil { return err } - if err := utils.WithProcfd(c.root, b.Destination, func(dstFd string) error { + defer subsystemDir.Close() + if err := utils.WithProcfdFile(subsystemDir, func(dstFd string) error { flags := defaultMountFlags if m.Flags&unix.MS_RDONLY != 0 { flags = flags | unix.MS_RDONLY @@ -503,10 +505,8 @@ func statfsToMountFlags(st unix.Statfs_t) int { return flags } -var errRootfsToFile = errors.New("config tries to change rootfs to file") - func (m *mountEntry) createOpenMountpoint(rootfs string) (Err error) { - unsafePath := utils.StripRoot(rootfs, m.Destination) + unsafePath := pathrs.LexicallyStripRoot(rootfs, m.Destination) dstFile, err := pathrs.OpenInRoot(rootfs, unsafePath, unix.O_PATH) defer func() { if dstFile != nil && Err != nil { @@ -543,22 +543,10 @@ func (m *mountEntry) createOpenMountpoint(rootfs string) (Err error) { } dstIsFile = !fi.IsDir() } - - // In previous runc versions, we would tolerate nonsense paths with - // dangling symlinks as path components. pathrs-lite does not support - // this, so instead we have to emulate this behaviour by doing - // SecureJoin *purely to get a semi-reasonable path to use* and then we - // use pathrs-lite to operate on the path safely. - newUnsafePath, err := securejoin.SecureJoin(rootfs, unsafePath) - if err != nil { - return err - } - unsafePath = utils.StripRoot(rootfs, newUnsafePath) - if dstIsFile { dstFile, err = pathrs.CreateInRoot(rootfs, unsafePath, unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW, 0o644) } else { - dstFile, err = pathrs.MkdirAllInRootOpen(rootfs, unsafePath, 0o755) + dstFile, err = pathrs.MkdirAllInRoot(rootfs, unsafePath, 0o755) } if err != nil { return fmt.Errorf("make mountpoint %q: %w", m.Destination, err) @@ -620,7 +608,7 @@ func mountToRootfs(c *mountConfig, m mountEntry) error { } else if !fi.IsDir() { return fmt.Errorf("filesystem %q must be mounted on ordinary directory", m.Device) } - dstFile, err := pathrs.MkdirAllInRootOpen(rootfs, dest, 0o755) + dstFile, err := pathrs.MkdirAllInRoot(rootfs, dest, 0o755) if err != nil { return err } @@ -952,7 +940,7 @@ func createDevices(config *configs.Config) error { for _, node := range config.Devices { // The /dev/ptmx device is setup by setupPtmx() - if utils.CleanPath(node.Path) == "/dev/ptmx" { + if pathrs.LexicallyCleanPath(node.Path) == "/dev/ptmx" { continue } @@ -983,15 +971,7 @@ func createDeviceNode(rootfs string, node *devices.Device, bind bool) error { // The node only exists for cgroup reasons, ignore it here. return nil } - destPath, err := securejoin.SecureJoin(rootfs, node.Path) - if err != nil { - return err - } - if destPath == rootfs { - return fmt.Errorf("%w: mknod over rootfs", errRootfsToFile) - } - destDirPath, destName := filepath.Split(destPath) - destDir, err := pathrs.MkdirAllInRootOpen(rootfs, destDirPath, 0o755) + destDir, destName, err := pathrs.MkdirAllParentInRoot(rootfs, node.Path, 0o755) if err != nil { return fmt.Errorf("mkdir parent of device inode %q: %w", node.Path, err) } @@ -1392,7 +1372,7 @@ func reopenAfterMount(rootfs string, f *os.File, flags int) (_ *os.File, Err err if !pathrs.IsLexicallyInRoot(rootfs, fullPath) { return nil, fmt.Errorf("mountpoint %q is outside of rootfs %q", fullPath, rootfs) } - unsafePath := utils.StripRoot(rootfs, fullPath) + unsafePath := pathrs.LexicallyStripRoot(rootfs, fullPath) reopened, err := pathrs.OpenInRoot(rootfs, unsafePath, flags) if err != nil { return nil, fmt.Errorf("re-open mountpoint %q: %w", unsafePath, err) @@ -1432,7 +1412,7 @@ func (m *mountEntry) mountPropagate(rootfs string, mountLabel string) error { // operations on it. We need to set up files in "/dev", and other tmpfs // mounts may need to be chmod-ed after mounting. These mounts will be // remounted ro later in finalizeRootfs(), if necessary. - if m.Device == "tmpfs" || utils.CleanPath(m.Destination) == "/dev" { + if m.Device == "tmpfs" || pathrs.LexicallyCleanPath(m.Destination) == "/dev" { flags &= ^unix.MS_RDONLY } @@ -1456,9 +1436,7 @@ func (m *mountEntry) mountPropagate(rootfs string, mountLabel string) error { _ = m.dstFile.Close() m.dstFile = newDstFile - // We have to apply mount propagation flags in a separate WithProcfd() call - // because the previous call invalidates the passed procfd -- the mount - // target needs to be re-opened. + // Apply the propagation flags on the new mount. if err := utils.WithProcfdFile(m.dstFile, func(dstFd string) error { for _, pflag := range m.PropagationFlags { if err := mountViaFds("", nil, m.Destination, dstFd, "", uintptr(pflag), ""); err != nil { diff --git a/libcontainer/specconv/spec_linux.go b/libcontainer/specconv/spec_linux.go index ae2dd5d2715..214197c3db5 100644 --- a/libcontainer/specconv/spec_linux.go +++ b/libcontainer/specconv/spec_linux.go @@ -16,17 +16,17 @@ import ( systemdDbus "github.com/coreos/go-systemd/v22/dbus" dbus "github.com/godbus/dbus/v5" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + "github.com/opencontainers/cgroups" devices "github.com/opencontainers/cgroups/devices/config" "github.com/opencontainers/runc/internal/linux" + "github.com/opencontainers/runc/internal/pathrs" "github.com/opencontainers/runc/libcontainer/configs" "github.com/opencontainers/runc/libcontainer/internal/userns" "github.com/opencontainers/runc/libcontainer/seccomp" - libcontainerUtils "github.com/opencontainers/runc/libcontainer/utils" - "github.com/opencontainers/runtime-spec/specs-go" - "github.com/sirupsen/logrus" - - "golang.org/x/sys/unix" ) var ( @@ -802,7 +802,7 @@ func CreateCgroupConfig(opts *CreateOpts, defaultDevs []*devices.Device) (*cgrou if useSystemdCgroup { myCgroupPath = spec.Linux.CgroupsPath } else { - myCgroupPath = libcontainerUtils.CleanPath(spec.Linux.CgroupsPath) + myCgroupPath = pathrs.LexicallyCleanPath(spec.Linux.CgroupsPath) } } diff --git a/libcontainer/utils/utils.go b/libcontainer/utils/utils.go index e4a9013a721..6c3baf0153c 100644 --- a/libcontainer/utils/utils.go +++ b/libcontainer/utils/utils.go @@ -3,11 +3,11 @@ package utils import ( "encoding/json" "io" - "os" - "path/filepath" "strings" "golang.org/x/sys/unix" + + "github.com/opencontainers/runc/internal/pathrs" ) const ( @@ -36,53 +36,6 @@ func WriteJSON(w io.Writer, v any) error { return err } -// CleanPath makes a path safe for use with filepath.Join. This is done by not -// only cleaning the path, but also (if the path is relative) adding a leading -// '/' and cleaning it (then removing the leading '/'). This ensures that a -// path resulting from prepending another path will always resolve to lexically -// be a subdirectory of the prefixed path. This is all done lexically, so paths -// that include symlinks won't be safe as a result of using CleanPath. -func CleanPath(path string) string { - // Deal with empty strings nicely. - if path == "" { - return "" - } - - // Ensure that all paths are cleaned (especially problematic ones like - // "/../../../../../" which can cause lots of issues). - - if filepath.IsAbs(path) { - return filepath.Clean(path) - } - - // If the path isn't absolute, we need to do more processing to fix paths - // such as "../../../..//some/path". We also shouldn't convert absolute - // paths to relative ones. - path = filepath.Clean(string(os.PathSeparator) + path) - // This can't fail, as (by definition) all paths are relative to root. - path, _ = filepath.Rel(string(os.PathSeparator), path) - - return path -} - -// StripRoot returns the passed path, stripping the root path if it was -// (lexicially) inside it. Note that both passed paths will always be treated -// as absolute, and the returned path will also always be absolute. In -// addition, the paths are cleaned before stripping the root. -func StripRoot(root, path string) string { - // Make the paths clean and absolute. - root, path = CleanPath("/"+root), CleanPath("/"+path) - switch { - case path == root: - path = "/" - case root == "/": - // do nothing - default: - path = strings.TrimPrefix(path, root+"/") - } - return CleanPath("/" + path) -} - // SearchLabels searches through a list of key=value pairs for a given key, // returning its value, and the binary flag telling whether the key exist. func SearchLabels(labels []string, key string) (string, bool) { @@ -113,3 +66,23 @@ func Annotations(labels []string) (bundle string, userAnnotations map[string]str } return bundle, userAnnotations } + +// CleanPath makes a path safe for use with filepath.Join. This is done by not +// only cleaning the path, but also (if the path is relative) adding a leading +// '/' and cleaning it (then removing the leading '/'). This ensures that a +// path resulting from prepending another path will always resolve to lexically +// be a subdirectory of the prefixed path. This is all done lexically, so paths +// that include symlinks won't be safe as a result of using CleanPath. +// +// Deprecated: This function has been moved to internal/pathrs and this wrapper +// will be removed in runc 1.5. +var CleanPath = pathrs.LexicallyCleanPath + +// StripRoot returns the passed path, stripping the root path if it was +// (lexicially) inside it. Note that both passed paths will always be treated +// as absolute, and the returned path will also always be absolute. In +// addition, the paths are cleaned before stripping the root. +// +// Deprecated: This function has been moved to internal/pathrs and this wrapper +// will be removed in runc 1.5. +var StripRoot = pathrs.LexicallyStripRoot diff --git a/libcontainer/utils/utils_test.go b/libcontainer/utils/utils_test.go index 4b5fd833cdf..5953118efcc 100644 --- a/libcontainer/utils/utils_test.go +++ b/libcontainer/utils/utils_test.go @@ -70,70 +70,3 @@ func TestWriteJSON(t *testing.T) { t.Errorf("expected to write %s but was %s", expected, b.String()) } } - -func TestCleanPath(t *testing.T) { - path := CleanPath("") - if path != "" { - t.Errorf("expected to receive empty string and received %s", path) - } - - path = CleanPath("rootfs") - if path != "rootfs" { - t.Errorf("expected to receive 'rootfs' and received %s", path) - } - - path = CleanPath("../../../var") - if path != "var" { - t.Errorf("expected to receive 'var' and received %s", path) - } - - path = CleanPath("/../../../var") - if path != "/var" { - t.Errorf("expected to receive '/var' and received %s", path) - } - - path = CleanPath("/foo/bar/") - if path != "/foo/bar" { - t.Errorf("expected to receive '/foo/bar' and received %s", path) - } - - path = CleanPath("/foo/bar/../") - if path != "/foo" { - t.Errorf("expected to receive '/foo' and received %s", path) - } -} - -func TestStripRoot(t *testing.T) { - for _, test := range []struct { - root, path, out string - }{ - // Works with multiple components. - {"/a/b", "/a/b/c", "/c"}, - {"/hello/world", "/hello/world/the/quick-brown/fox", "/the/quick-brown/fox"}, - // '/' must be a no-op. - {"/", "/a/b/c", "/a/b/c"}, - // Must be the correct order. - {"/a/b", "/a/c/b", "/a/c/b"}, - // Must be at start. - {"/abc/def", "/foo/abc/def/bar", "/foo/abc/def/bar"}, - // Must be a lexical parent. - {"/foo/bar", "/foo/barSAMECOMPONENT", "/foo/barSAMECOMPONENT"}, - // Must only strip the root once. - {"/foo/bar", "/foo/bar/foo/bar/baz", "/foo/bar/baz"}, - // Deal with .. in a fairly sane way. - {"/foo/bar", "/foo/bar/../baz", "/foo/baz"}, - {"/foo/bar", "../../../../../../foo/bar/baz", "/baz"}, - {"/foo/bar", "/../../../../../../foo/bar/baz", "/baz"}, - {"/foo/bar/../baz", "/foo/baz/bar", "/bar"}, - {"/foo/bar/../baz", "/foo/baz/../bar/../baz/./foo", "/foo"}, - // All paths are made absolute before stripping. - {"foo/bar", "/foo/bar/baz/bee", "/baz/bee"}, - {"/foo/bar", "foo/bar/baz/beef", "/baz/beef"}, - {"foo/bar", "foo/bar/baz/beets", "/baz/beets"}, - } { - got := StripRoot(test.root, test.path) - if got != test.out { - t.Errorf("StripRoot(%q, %q) -- got %q, expected %q", test.root, test.path, got, test.out) - } - } -} diff --git a/libcontainer/utils/utils_unix.go b/libcontainer/utils/utils_unix.go index a457a09b00e..af5689e9a2d 100644 --- a/libcontainer/utils/utils_unix.go +++ b/libcontainer/utils/utils_unix.go @@ -13,10 +13,11 @@ import ( _ "unsafe" // for go:linkname securejoin "github.com/cyphar/filepath-securejoin" - "github.com/opencontainers/runc/internal/linux" - "github.com/opencontainers/runc/internal/pathrs" "github.com/sirupsen/logrus" "golang.org/x/sys/unix" + + "github.com/opencontainers/runc/internal/linux" + "github.com/opencontainers/runc/internal/pathrs" ) var ( @@ -151,9 +152,12 @@ func NewSockPair(name string) (parent, child *os.File, err error) { // through the passed fdpath should be safe. Do not access this path through // the original path strings, and do not attempt to use the pathname outside of // the passed closure (the file handle will be freed once the closure returns). +// +// Deprecated: This function is an internal implementation detail of runc and +// is no longer used. It will be removed in runc 1.5. func WithProcfd(root, unsafePath string, fn func(procfd string) error) error { // Remove the root then forcefully resolve inside the root. - unsafePath = StripRoot(root, unsafePath) + unsafePath = pathrs.LexicallyStripRoot(root, unsafePath) fullPath, err := securejoin.SecureJoin(root, unsafePath) if err != nil { return fmt.Errorf("resolving path inside rootfs failed: %w", err) @@ -180,10 +184,15 @@ func WithProcfd(root, unsafePath string, fn func(procfd string) error) error { return fn(procfd) } -// WithProcfdFile is a very minimal wrapper around [ProcThreadSelfFd], intended -// to make migrating from [WithProcfd] and [WithProcfdPath] usage easier. The +// WithProcfdFile is a very minimal wrapper around [ProcThreadSelfFd]. The // caller is responsible for making sure that the provided file handle is // actually safe to operate on. +// +// NOTE: THIS FUNCTION IS INTERNAL TO RUNC, DO NOT USE IT. +// +// TODO: Migrate the mount logic towards a more move_mount(2)-friendly design +// where this is kind of /proc/self/... tomfoolery is only done in a fallback +// path for old kernels. func WithProcfdFile(file *os.File, fn func(procfd string) error) error { fdpath, closer := ProcThreadSelfFd(file.Fd()) defer closer() diff --git a/tests/integration/mounts.bats b/tests/integration/mounts.bats index b60c88ae27c..6bab15727a3 100644 --- a/tests/integration/mounts.bats +++ b/tests/integration/mounts.bats @@ -127,6 +127,42 @@ function test_mount_order() { [[ "$output" == *"a/x"* ]] # the final "file" was from a/x. } +# This needs to be placed at the top of the bats file to work around +# a shellcheck bug. See . +test_mount_target() { + src="$1" + dst="$2" + real_dst="${3:-$dst}" + + echo "== $src -> $dst (=> $real_dst) ==" + + old_config="$(mktemp ./config.json.bak.XXXXXX)" + cp ./config.json "$old_config" + + update_config '.mounts += [{ + source: "'"$src"'", + destination: "'"$dst"'", + options: ["bind"] + }]' + + # Make sure the target path is at the right spot and is actually a + # bind-mount of the correct inode. + update_config '.process.args = ["stat", "-c", "%n %d:%i", "--", "'"$real_dst"'"]' + runc run test_busybox + [ "$status" -eq 0 ] + [[ "$output" == "$real_dst $(stat -c "%d:%i" -- "$src")" ]] + + # Make sure there is a mount entry for the target path. + # shellcheck disable=SC2016 + update_config '.process.args = ["awk", "-F", "PATH='"$real_dst"'", "$2 == PATH", "/proc/self/mounts"]' + runc run test_busybox + [ "$status" -eq 0 ] + [[ "$output" == *"$real_dst"* ]] + + # Switch back the old config so this function can be called multiple times. + mv "$old_config" "./config.json" +} + # https://github.com/opencontainers/runc/issues/3991 @test "runc run [tmpcopyup]" { mkdir -p rootfs/dir1/dir2 @@ -343,3 +379,25 @@ function test_mount_order() { @test "runc run [mount order, container idmap source] (userns)" { test_mount_order userns,idmap } + +@test "runc run [bind mount through a dangling symlink component]" { + rm -rf rootfs/etc/hosts + ln -s /tmp/foo/bar rootfs/jump + ln -s /jump/baz/hosts rootfs/etc/hosts + + rm -rf rootfs/tmp/foo + test_mount_target ./config.json /etc/hosts /tmp/foo/bar/baz/hosts +} + +@test "runc run [bind mount through a trailing dangling symlink]" { + rm -rf rootfs/etc/hosts + ln -s /tmp/hosts rootfs/etc/hosts + + # File. + rm -rf rootfs/tmp/hosts + test_mount_target ./config.json /etc/hosts /tmp/hosts + + # Directory. + rm -rf rootfs/tmp/hosts + test_mount_target . /etc/hosts /tmp/hosts +}