Skip to content

archive: Resolve symlinks one component at a time - #101

Open
vvoland wants to merge 1 commit into
moby:mainfrom
vvoland:escape-chained
Open

archive: Resolve symlinks one component at a time#101
vvoland wants to merge 1 commit into
moby:mainfrom
vvoland:escape-chained

Conversation

@vvoland

@vvoland vvoland commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Path resolution passed multi-component paths to os.Lstat and os.Readlink, so the kernel followed symlinks in the intermediate components. The walk never read those links, so it did not record a relative escape, and resolution depended on symlinks located outside the extraction destination.

Given:

dest/a -> x/escape dest/x/escape -> ../.. dest/../absolute -> /target

both Unpack and UnpackLayer accepted the entry a/absolute/file and wrote dest/target/file through the absolute symlink next to dest, because only a was read as a link and its target x/escape is local.

Now the walk pops one component at a time and joins it to the already-resolved prefix, so every os.Lstat and os.Readlink call observes exactly the link that is about to be followed. That prefix contains no symlink, so ".." can pop it lexically, and it stays at the root. The escaping link is recorded again and the entry above is rejected with os.Root's path-escape error.

Path resolution passed multi-component paths to os.Lstat and
os.Readlink, so the kernel followed symlinks in the intermediate
components. The walk never read those links, so it did not record a
relative escape, and resolution depended on symlinks located outside
the extraction destination.

Given:

dest/a -> x/escape dest/x/escape -> ../.. dest/../absolute -> /target

both Unpack and UnpackLayer accepted the entry `a/absolute/file` and
wrote `dest/target/file` through the absolute symlink next to dest,
because only `a` was read as a link and its target `x/escape` is
local.

Now the walk pops one component at a time and joins it to the
already-resolved prefix, so every os.Lstat and os.Readlink call
observes exactly the link that is about to be followed. That prefix
contains no symlink, so ".." can pop it lexically, and it stays at the
root. The escaping link is recorded again and the entry above is
rejected with os.Root's path-escape error.

Signed-off-by: Paweł Gronowski <git@grono.dev>
@vvoland
vvoland requested a review from thaJeztah August 3, 2026 13:16
@vvoland vvoland self-assigned this Aug 3, 2026
@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.13043% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.56%. Comparing base (216738e) to head (5d0e839).
⚠️ Report is 70 commits behind head on main.

Files with missing lines Patch % Lines
rootpath.go 89.13% 3 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #101      +/-   ##
==========================================
- Coverage   65.81%   65.56%   -0.25%     
==========================================
  Files          42       44       +2     
  Lines        2039     2309     +270     
==========================================
+ Hits         1342     1514     +172     
- Misses        519      586      +67     
- Partials      178      209      +31     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vvoland
vvoland marked this pull request as ready for review August 3, 2026 13:26
@thaJeztah

Copy link
Copy Markdown
Member

We should probably upstream this as well in https://github.com/containerd/continuity

Comment thread rootpath.go
resolved = next
continue
}
if fi.Mode()&os.ModeSymlink == 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a follow-up, we should look at the Windows side of things, with go1.23 (?) no longer exposing all kind of links as symlink.

Some quick vibe-coded code to check (for windows);

func linkTarget(path string, fi os.FileInfo) (string, bool, error) {
	mode := fi.Mode()

	// Regular symbolic links are still reported as ModeSymlink.
	if mode&os.ModeSymlink != 0 {
		target, err := os.Readlink(path)
		return target, true, err
	}

	// Since Go 1.23, junctions and other name-surrogate reparse points are
	// generally reported as ModeIrregular rather than ModeSymlink.
	if mode&os.ModeIrregular == 0 {
		return "", false, nil
	}

	target, err := os.Readlink(path)
	if err == nil {
		return target, true, nil
	}

	// An irregular object that Readlink cannot interpret is not necessarily
	// safe to traverse. Return the error rather than treating it as an
	// ordinary directory or file.
	if errors.Is(err, syscall.ERROR_NOT_A_REPARSE_POINT) ||
		errors.Is(err, syscall.ERROR_INVALID_REPARSE_DATA) {
		return "", false, &fs.PathError{
			Op:   "readlink",
			Path: path,
			Err:  err,
		}
	}
	return "", false, err
}

And call-site;

fi, err := os.Lstat(candidate)
if err != nil {
	return "", err
}

target, isLink, err := linkTarget(candidate, fi)
if err != nil {
	return "", err
}
if !isLink {
	// Continue with ordinary component.
	continue
}
// ....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants