Merge pull request #4985 from cyphar/hallucinated-paths

pathrs: add "hallucination" helpers for SecureJoin magic
This commit is contained in:
lfbzhm
2025-11-26 22:12:59 +08:00
committed by GitHub
14 changed files with 335 additions and 205 deletions
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: Apache-2.0
/*
* Copyright (C) 2024-2025 Aleksa Sarai <cyphar@cyphar.com>
* 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
}
+5 -25
View File
@@ -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
}
+82
View File
@@ -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 "../../../../<etc>/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
}
+67
View File
@@ -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)
}
}
}
+1 -8
View File
@@ -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
}
+1 -2
View File
@@ -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
+11 -4
View File
@@ -25,6 +25,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"
)
@@ -555,16 +556,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.
+2 -2
View File
@@ -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
}
+15 -37
View File
@@ -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 {
+6 -6
View File
@@ -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)
}
}
+22 -49
View File
@@ -4,11 +4,11 @@ package utils
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
"github.com/opencontainers/runc/internal/pathrs"
)
const (
@@ -37,53 +37,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 "../../../../<etc>/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) {
@@ -114,3 +67,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
-67
View File
@@ -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)
}
}
}
+14 -5
View File
@@ -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()
+58
View File
@@ -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 <https://github.com/koalaman/shellcheck/issues/2873>.
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
}