Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 100 additions & 21 deletions archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,77 @@ func (ta *tarAppender) addTarFile(srcPath, archivePath string) error {
return nil
}

// resolveArchivePath resolves intermediate symlinks in name using chroot-like
// semantics when os.Root cannot traverse them. The final path component is
// intentionally preserved because archive extraction may create or replace it.
//
// This is a compatibility workaround rather than the preferred long-term
// implementation. It resolves the path separately before the actual operation,
// so a concurrent filesystem change may cause the operation to affect a
// different path within root. The subsequent os.Root operation still confines
// the operation to root and prevents such a change from escaping it.
//
// Paths with missing components are supported. Existing symlinks are resolved,
// and any remaining nonexistent components are retained for later creation.
//
// This helper should eventually be replaced by handle-relative resolution and
// operations with resolve-in-root semantics, avoiding the resolution/use race
// and repeated path traversal.
func resolveArchivePath(root *os.Root, name string) (string, error) {
parent, base := filepath.Split(name)
if parent == "" {
return name, nil
}

parent = filepath.Clean(parent)

// Follow the final parent component: it is an intermediate component of name,
// and an absolute symlink there must trigger the resolve-in-root fallback.
_, statErr := root.Stat(parent)
switch {
case statErr == nil:
return name, nil
case !os.IsNotExist(statErr) && !isPathEscapes(statErr):
return "", statErr
}

// Resolve the parent both to handle ENOENT from missing components or dangling
// symlinks, and to determine whether an os.Root breakout was caused by an
// absolute symlink. Relative symlink escapes preserve the original Stat error.
resolved, err := resolveFSRootPath(root.Name(), parent)
if err != nil {
return "", err
}

if isPathEscapes(statErr) && (!resolved.followedAbsoluteLink || resolved.relativeEscapeBeforeAbsolute) {
return "", statErr
}

relParent, err := filepath.Rel(root.Name(), resolved.path)
if err != nil {
return "", err
}
if relParent != "." && !filepath.IsLocal(relParent) {
return "", breakoutError(fmt.Errorf(
"resolved parent %q escapes root %q",
resolved.path,
root.Name(),
))
}

return filepath.Join(relParent, base), nil
}

// resolveHardlinkTarget validates a POSIX hardlink target and resolves it to
// the native, root-relative filesystem path used for extraction.
func resolveHardlinkTarget(root *os.Root, linkname string) (string, error) {
cleaned := path.Clean(linkname)
if cleaned == "." || !filepath.IsLocal(cleaned) {
return "", breakoutError(fmt.Errorf("invalid hardlink target %q", linkname))
}
return resolveArchivePath(root, filepath.FromSlash(cleaned))
}

// createTarFile extracts a single tar entry into the given root. dstPath is the
// root-relative path of the entry being extracted, in native (host-separator)
// form so it can be passed directly to os.Root methods and fsRootPath.
Expand All @@ -462,6 +533,15 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea
// so use hdrInfo.Mode() (they differ for e.g. setuid bits)
hdrInfo := hdr.FileInfo()

var hardlinkTarget string
if hdr.Typeflag == tar.TypeLink {
var err error
hardlinkTarget, err = resolveHardlinkTarget(root, hdr.Linkname)
if err != nil {
return err
}
}

switch hdr.Typeflag {
case tar.TypeDir:
// Create directory unless it already exists as one; merge in that case.
Expand Down Expand Up @@ -511,13 +591,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea
}

case tar.TypeLink:
// Defence in depth: root.Link's containment is limited when
// dest is a volume root.
linkname := path.Clean(hdr.Linkname)
if linkname == "." || !filepath.IsLocal(linkname) {
return breakoutError(fmt.Errorf("invalid hardlink target %q", hdr.Linkname))
}
if err := root.Link(filepath.FromSlash(linkname), dstPath); err != nil {
if err := root.Link(hardlinkTarget, dstPath); err != nil {
return err
}

Expand Down Expand Up @@ -593,7 +667,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea

// There is no LChmod, so ignore mode for symlink. Also, this
// must happen after chown, as that can modify the file mode
if err := handleLChmod(root, dstPath, hdr, hdrInfo); err != nil {
if err := handleLChmod(root, dstPath, hardlinkTarget, hdr, hdrInfo); err != nil {
return err
}

Expand All @@ -608,7 +682,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea
}
case tar.TypeLink:
// Follow the hardlink only when its target is not itself a symlink.
fi, err := root.Lstat(filepath.FromSlash(path.Clean(hdr.Linkname)))
fi, err := root.Lstat(hardlinkTarget)
if err == nil && fi.Mode()&os.ModeSymlink == 0 {
if err := chtimes(root, dstPath, aTime, mTime); err != nil {
return err
Expand Down Expand Up @@ -931,7 +1005,10 @@ loop:
// dstPath is the native (host-separator) form of the entry name,
// used at all filesystem boundaries (os.Root methods, fsRootPath).
// hdr.Name stays POSIX (forward-slash) for logical string checks.
dstPath := filepath.FromSlash(hdr.Name)
dstPath, err := resolveArchivePath(root, filepath.FromSlash(hdr.Name))
if err != nil {
return err
}

// If dstPath exists we almost always just want to remove and replace it.
// The only exception is when it is a directory *and* the file from
Expand Down Expand Up @@ -969,7 +1046,7 @@ loop:
//
// This must be done before whiteoutConverter.ConvertRead, which
// may set xattrs on the directory or create whiteout files.
if err := createImpliedDirectories(root, hdr, options); err != nil {
if err := createImpliedDirectories(root, dstPath, options); err != nil {
return err
}

Expand Down Expand Up @@ -1024,17 +1101,19 @@ func unrepresentableOnWindows(hdr *tar.Header) error {
return nil
}

// createImpliedDirectories will create all parent directories of the current path with default permissions, if they do
// not already exist. This is possible as the tar format supports 'implicit' directories, where their existence is
// defined by the paths of files in the tar, but there are no header entries for the directories themselves, and thus
// we most both create them and choose metadata like permissions.
// createImpliedDirectories creates all parent directories of dstPath with
// default permissions if they do not already exist. This is necessary because
// the tar format permits implicit directories whose existence is defined only
// by file paths, without corresponding directory headers from which metadata
// could be restored.
//
// The caller must have normalized hdr.Name (no leading ".." components).
// All directory creation is performed via root so it is bounded within the
// destination at the OS level (openat(2) semantics), preventing escape via
// symlinks in the destination tree.
func createImpliedDirectories(root *os.Root, hdr *tar.Header, options *TarOptions) error {
parent := filepath.FromSlash(path.Dir(strings.TrimSuffix(hdr.Name, "/")))
// The caller must pass a normalized, root-relative local path. Any archive-path
// conversion and resolve-in-root handling must already have been applied.
// Directory creation is performed through root, so it remains confined to the
// extraction destination even if the destination tree changes concurrently.
func createImpliedDirectories(root *os.Root, dstPath string, options *TarOptions) error {
parent := filepath.Dir(dstPath)

// Skip when the parent is the root itself; nothing to create.
if parent == "." || parent == "" {
return nil
Expand Down
5 changes: 2 additions & 3 deletions archive_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"math"
"os"
"path"
"path/filepath"
"strings"
"syscall"
Expand Down Expand Up @@ -88,15 +87,15 @@ func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, dstPath string)
// handleLChmod applies the mode from hdrInfo to dstPath within root, skipping
// symlinks (there is no lchmod). For hardlinks, the mode is applied only when
// the link target is itself not a symlink.
func handleLChmod(root *os.Root, dstPath string, hdr *tar.Header, hdrInfo os.FileInfo) error {
func handleLChmod(root *os.Root, dstPath string, hardlinkTarget string, hdr *tar.Header, hdrInfo os.FileInfo) error {
switch hdr.Typeflag {
case tar.TypeSymlink:
return nil

case tar.TypeLink:
// If the target is a symlink, there is no way to chmod the hardlink
// without following it.
fi, err := root.Lstat(filepath.FromSlash(path.Clean(hdr.Linkname)))
fi, err := root.Lstat(hardlinkTarget)
if err != nil || fi.Mode()&os.ModeSymlink != 0 {
return nil
}
Expand Down
154 changes: 154 additions & 0 deletions archive_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,157 @@ func TestHandleTarTypeBlockCharFifoDeviceRange(t *testing.T) {
})
}
}

// TestUntarThroughAbsoluteSymlink verifies that archive extraction follows a
// pre-existing absolute symlink relative to the extraction root, including
// when the symlink target or directories following it do not yet exist.
//
// Regression test for https://github.com/moby/moby/issues/53258
func TestUntarThroughAbsoluteSymlink(t *testing.T) {
unpackers := []struct {
name string
unpack func(dest string, r io.Reader) error
}{
{
name: "Untar",
unpack: func(dest string, r io.Reader) error {
return Untar(r, dest, &TarOptions{NoLchown: true})
},
},
{
name: "UnpackLayer",
unpack: func(dest string, r io.Reader) error {
_, err := UnpackLayer(dest, r, &TarOptions{NoLchown: true})
return err
},
},
}

for _, unpacker := range unpackers {
t.Run(unpacker.name, func(t *testing.T) {
for _, tc := range []struct {
name string
createTarget bool
}{
{
name: "existing target",
createTarget: true,
},
{
name: "missing target",
createTarget: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
const (
name = "var/run/existing/non-existing/file"
content = "content"
)

dest := t.TempDir()
assert.NilError(t, os.Mkdir(filepath.Join(dest, "var"), 0o755))
if tc.createTarget {
assert.NilError(t, os.MkdirAll(
filepath.Join(dest, "run", "existing"),
0o755,
))
}
assert.NilError(t, os.Symlink(
"/run",
filepath.Join(dest, "var", "run"),
))

buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
assert.NilError(t, tw.WriteHeader(&tar.Header{
Name: name,
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: int64(len(content)),
}))
_, err := io.WriteString(tw, content)
assert.NilError(t, err)
assert.NilError(t, tw.Close())

assert.NilError(t, unpacker.unpack(dest, buf))

actual, err := os.ReadFile(filepath.Join(
dest, "run", "existing", "non-existing", "file",
))
assert.NilError(t, err)
assert.DeepEqual(t, actual, []byte(content))
})
}
})
}
}

// Absolute symlinks are common in container root filesystems and may come from
// a lower layer. Later layers must resolve files and hardlink sources through
// those symlinks relative to the extraction root, not the host root.
func TestHardlinkSourceThroughAbsoluteSymlink(t *testing.T) {
const content = "content"

unpackers := []struct {
name string
unpack func(io.Reader, string) error
}{
{
name: "Unpack",
unpack: func(r io.Reader, dest string) error {
return Unpack(r, dest, &TarOptions{NoLchown: true})
},
},
{
name: "UnpackLayer",
unpack: func(r io.Reader, dest string) error {
_, err := UnpackLayer(dest, r, &TarOptions{NoLchown: true})
return err
},
},
}

for _, tc := range unpackers {
t.Run(tc.name, func(t *testing.T) {
dest := t.TempDir()
assert.NilError(t, os.Mkdir(filepath.Join(dest, "var"), 0o755))
assert.NilError(t, os.Symlink("/run", filepath.Join(dest, "var", "run")))

buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
assert.NilError(t, tw.WriteHeader(&tar.Header{
Name: "var/run/source",
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: int64(len(content)),
}))
_, err := io.WriteString(tw, content)
assert.NilError(t, err)
assert.NilError(t, tw.WriteHeader(&tar.Header{
Name: "var/run/link",
Typeflag: tar.TypeLink,
Linkname: "var/run/source",
Mode: 0o644,
}))
assert.NilError(t, tw.Close())

assert.NilError(t, tc.unpack(buf, dest))

source := filepath.Join(dest, "run", "source")
link := filepath.Join(dest, "run", "link")
actual, err := os.ReadFile(link)
assert.NilError(t, err)
assert.DeepEqual(t, actual, []byte(content))

sourceInode, err := getInode(source)
assert.NilError(t, err)
linkInode, err := getInode(link)
assert.NilError(t, err)
assert.Equal(t, sourceInode, linkInode)

linkCount, err := getNlink(source)
assert.NilError(t, err)
assert.Equal(t, linkCount, uint64(2))
})
}
}
2 changes: 1 addition & 1 deletion archive_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, path string) err
}

// handleLChmod is a no-op on Windows because chmod is not supported.
func handleLChmod(root *os.Root, path string, hdr *tar.Header, hdrInfo os.FileInfo) error {
func handleLChmod(root *os.Root, dstPath string, hardlinkTarget string, hdr *tar.Header, hdrInfo os.FileInfo) error {
return nil
}

Expand Down
Loading
Loading