Skip to content

archive: replace static path check with symlink-aware safeResolve - #24

Closed
ctalledo wants to merge 2 commits into
moby:mainfrom
ctalledo:art-225-breakout-protections
Closed

archive: replace static path check with symlink-aware safeResolve#24
ctalledo wants to merge 2 commits into
moby:mainfrom
ctalledo:art-225-breakout-protections

Conversation

@ctalledo

@ctalledo ctalledo commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

A malicious tar archive can escape the extraction root by planting a
symlink chain that passes static string-based path checks but resolves
outside the root at runtime. Unpack and UnpackLayer validated entry
paths using filepath.Join + filepath.Rel, which does not follow
symlinks on disk. A two-hop chain (an in-root symlink to .. followed
by a second symlink whose target appears safe statically but escapes
after the first hop is resolved) bypasses both the entry-name check and
the existing symlink-target check in createTarFile.

On Linux this is mitigated by chrootarchive wrapping extraction in
chroot(2). On platforms without chroot support (Windows) and for
callers that bypass chrootarchive (e.g. BuildKit ADD --unpack),
the extraction root was not enforced.

This PR introduces safeResolve (ported from
containerd/continuity/fs.RootPath), which walks each path component
with os.Lstat and resolves symlinks within the extraction root,
bounding any targets that would escape back inside it.

Applied to:

  • Unpack: main path computation and deferred directory chtimes loop
  • UnpackLayer: same
  • createTarFile TypeLink: hardlink target resolution

Test plan

  • go test ./... passes
  • TestUntarSymlinkBreakout fails on the pre-fix code (confirmed
    locally): a two-hop symlink chain (inner/go_up -> "..",
    inner/go_up/escape -> "../victim") bypasses both the entry-name
    check and the existing symlink-target check in createTarFile, but
    is blocked by safeResolve

@ctalledo
ctalledo force-pushed the art-225-breakout-protections branch 4 times, most recently from d2b946f to 28dffd9 Compare May 27, 2026 21:57
@codecov-commenter

codecov-commenter commented May 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.68182% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.23%. Comparing base (f12e6e1) to head (c7f83f1).
⚠️ Report is 26 commits behind head on main.

Files with missing lines Patch % Lines
safepath.go 79.03% 8 Missing and 5 partials ⚠️
archive.go 82.35% 1 Missing and 2 partials ⚠️
diff.go 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #24      +/-   ##
==========================================
+ Coverage   65.09%   65.23%   +0.14%     
==========================================
  Files          42       43       +1     
  Lines        2833     2091     -742     
==========================================
- Hits         1844     1364     -480     
+ Misses        803      545     -258     
+ Partials      186      182       -4     

☔ 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.

@ctalledo
ctalledo force-pushed the art-225-breakout-protections branch 2 times, most recently from 52a850e to 89be728 Compare May 28, 2026 00:11
Comment thread archive.go Fixed
@ctalledo
ctalledo force-pushed the art-225-breakout-protections branch 2 times, most recently from 51f428c to 96cbab5 Compare May 28, 2026 00:23
Comment thread safepath.go Outdated
Comment thread safepath.go Outdated
@ctalledo
ctalledo force-pushed the art-225-breakout-protections branch from 96cbab5 to 45ebb8e Compare May 28, 2026 16:26
@ctalledo

Copy link
Copy Markdown
Contributor Author

Good catch — these had real Windows correctness problems, especially the filepath.IsAbs one: a tar-style absolute target like /etc/passwd is not absolute under Windows semantics, so it would have been silently joined relative to newdir. Pushed a refactor that operates on slash-separated paths internally (using the path package) and only converts to OS-native at the boundaries. PTAL.

@ctalledo
ctalledo requested a review from thaJeztah May 28, 2026 16:38
Comment thread archive.go Outdated
hdr.Name = filepath.Clean(hdr.Name)
// Normalize name: root with "/" to eliminate leading ".."
// components, then strip the leading "/" for a root-relative path.
hdr.Name = strings.TrimLeft(path.Join("/", hdr.Name), "/")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What was the reason for changing from filepath.Clean?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

filepath.Clean doesn't neutralize traversal and is platform-wrong for tar names: it keeps a leading .. (filepath.Clean("../../etc") -> "../../etc"), and it uses the OS separator while tar names are always forward-slash (so it mangles them on Windows). Anchoring at / makes path.Join("/", name) clamp .. at the root (can't climb above it), then TrimLeft makes it root-relative again; using path rather than filepath keeps it slash-based on every platform. The filepath.IsLocal check immediately after is the actual sanitizer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My concern is that it would now rewrite ../../etc/passwd into etc/passwd, whereas the previous logic would simply reject it completely as "%q is outside of %q"

In this case, would path.Clean be better?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch -- this restores the prior behaviour of rejecting traversal entries; the path.Join("/", name) clamp had started silently rewriting ../../etc/passwd to etc/passwd. Now path.Clean(strings.TrimLeft(name, "/")): still strips a leading / (lenient, as before), but keeps .. so IsLocal rejects escapes. Both Unpack and UnpackLayer (c7f83f1).

Comment thread safepath.go
// ("C:\foo" on Windows) — are bounded within root.
//
// This is ported from github.com/containerd/continuity/fs.RootPath.
func safeResolve(root, p string) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Outside of the difference in full/relative path args, is this implementation better than symlink.FollowSymlinkInScope ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point -- FollowSymlinkInScope is equivalent here and would drop the continuity port. But per @tonistiigi's review on #25 (safeResolve/absPath TOCTOU), the plan is to move these onto dir-fd *at ops (started in #26, finished in a follow-up) rather than resolve to a path string -- and FollowSymlinkInScope has the same TOCTOU caveat. I'll revisit the remaining callers then: migrate to fds or switch to FollowSymlinkInScope.

@ctalledo
ctalledo requested a review from vvoland June 8, 2026 22:26
ctalledo added a commit to ctalledo/go-archive that referenced this pull request Jun 10, 2026
The name normalisation in Unpack and UnpackLayer used
strings.TrimLeft(path.Join("/", hdr.Name), "/"), which silently
rewrote a traversal entry such as "../../etc/passwd" to an in-root
path ("etc/passwd") and accepted it, instead of rejecting it. That
diverged from the prior behaviour (which rejected such entries) and
left the following filepath.IsLocal check as dead code, since the
anchor-at-"/" clamp always produced a local path first.

Use path.Clean(strings.TrimLeft(hdr.Name, "/")) instead: strip a
leading "/" so absolute entries stay root-relative (lenient, as
before), but preserve a leading ".." so filepath.IsLocal rejects
entries that escape the root. This restores reject-on-traversal
semantics while staying forward-slash based for cross-platform tar
names. Raised by Pawel Gronowski on moby#24.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
Comment thread archive.go
Comment on lines 487 to 499
// path -> hdr.Linkname = targetPath
// e.g. /extractDir/path/to/symlink -> ../2/file = /extractDir/path/2/file
targetPath := filepath.Join(filepath.Dir(path), hdr.Linkname) // #nosec G305 -- The target path is checked for path traversal.

// the reason we don't need to check symlinks in the path (with FollowSymlinkInScope) is because
// that symlink would first have to be created, which would be caught earlier, at this very check:
if !strings.HasPrefix(targetPath, extractDir) {
return breakoutError(fmt.Errorf("invalid symlink %q -> %q", path, hdr.Linkname))
}
if err := os.Symlink(hdr.Linkname, path); err != nil {
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should also get the same hardening as hardlink?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For symlinks it's the link's location that needs containment, not the target -- the target is stored verbatim and may legitimately point outside root (e.g. /usr/lib), with containment applying when it's followed. #25 reworks this case to root.Symlink(hdr.Linkname, path), which bounds the location via os.Root.

@vvoland

vvoland commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@tonistiigi PTAL

Comment thread archive.go
Comment on lines +476 to +477
// inode itself, not its target).
targetDir, err := safeResolve(extractDir, filepath.Dir(hdr.Linkname))

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.

We should use path, not filepath when processing tar headers, because they're expected to always follow unix-semantics (forward slashes)

Suggested change
// inode itself, not its target).
targetDir, err := safeResolve(extractDir, filepath.Dir(hdr.Linkname))
// inode itself, not its target).
targetDir, err := safeResolve(extractDir, path.Dir(hdr.Linkname))

Comment thread archive.go Outdated
if err != nil {
return err
}
targetPath := filepath.Join(targetDir, filepath.Base(hdr.Linkname))

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.

This one would be a bit ugly, but here we're converting UNIX to platform-specific 🤔

I guess for Base it doesn't really make a difference, but it we want to be explicit that we're dealing with forward-slashes here;

targetPath := filepath.Join(targetDir, path.Base(hdr.Linkname))

Or more explicit that we're converting (FromSlash will be a no-op though);

base := path.Base(hdr.Linkname)
targetPath := filepath.Join(targetDir, filepath.FromSlash(hdr.Linkname))

Comment thread archive.go
Comment on lines 946 to -934
if !strings.HasSuffix(hdr.Name, string(os.PathSeparator)) {
parent := filepath.Dir(hdr.Name)
parentPath := filepath.Join(dest, parent)

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.

This looks incorrect: hdr.Name is a tar header path, so it uses Unix (slash) semantics, so we can't use os.PathSeparator; and should use strings.HasSuffix(hdr.Name, "/").

BUT perhaps better, to use hdr.Typeflag != tar.TypeDir to make sure we're dealing with a file (not a directory) here instead of using the trailing /.

If we do, we should still trim the trailing slash before calling path.Dir, otherwise path.Dir("foo/bar/") returns "foo/bar" instead of "foo".

if hdr.Typeflag != tar.TypeDir {
	parent := path.Dir(strings.TrimSuffix(hdr.Name, "/"))
	// ...
}

Comment thread safepath.go
Comment on lines +39 to +41
// Normalise the input to forward slashes so that all internal path
// operations have consistent semantics on every platform.
p = filepath.ToSlash(p)

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.

Slightly wondering if we should leave this out, and to require callers to always use Unix (tar header format) semantics for p instead of silently correcting if it's not.

Looking at the code (also before this PR) it looks like we have many places where we handle tar-headers using local platform semantics (filepath instead of path). I think that could silently swallow subtle things; e.g. (on Windows);

hdr.Name = `foo\bar`

When using filepath;

filepath.Base(hdr.Name) // "bar"
filepath.Dir(hdr.Name)  // "foo"

But with path;

path.Base(hdr.Name) // "foo\bar"
path.Dir(hdr.Name)  // "."

ctalledo added 2 commits July 15, 2026 18:08
The Unpack and UnpackLayer functions validated tar entry paths using a
pure string check (filepath.Join + filepath.Rel). This does not follow
symlinks, so a malicious archive could plant a symlink pointing outside
the extraction root and then write files through it, bypassing the check.

On Linux this is mitigated by chrootarchive wrapping extraction in a
chroot(2) call. On platforms without chroot support (Windows) and for
callers that bypass chrootarchive (e.g. BuildKit ADD --unpack), the
extraction root is not enforced.

Introduce safeResolve (ported from containerd/continuity/fs.RootPath),
which walks each path component with os.Lstat and resolves symlinks
within the extraction root, bounding absolute targets and relative
targets that would escape root back inside it.

Apply safeResolve to:
- Unpack: main path computation and the deferred directory chtimes loop
- UnpackLayer: same
- createTarFile TypeLink: hardlink target resolution

Add a regression test for the symlink-chain bypass: a within-dest
symlink (go_up -> "..") used to redirect a second symlink outside dest
(escape -> "../victim"), which the static check missed.

Fixes ART-225.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
The name normalisation in Unpack and UnpackLayer used
strings.TrimLeft(path.Join("/", hdr.Name), "/"), which silently
rewrote a traversal entry such as "../../etc/passwd" to an in-root
path ("etc/passwd") and accepted it, instead of rejecting it. That
diverged from the prior behaviour (which rejected such entries) and
left the following filepath.IsLocal check as dead code, since the
anchor-at-"/" clamp always produced a local path first.

Use path.Clean(strings.TrimLeft(hdr.Name, "/")) instead: strip a
leading "/" so absolute entries stay root-relative (lenient, as
before), but preserve a leading ".." so filepath.IsLocal rejects
entries that escape the root. This restores reject-on-traversal
semantics while staying forward-slash based for cross-platform tar
names. Raised by Pawel Gronowski on moby#24.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
@thaJeztah
thaJeztah force-pushed the art-225-breakout-protections branch from c7f83f1 to 1c897b6 Compare July 15, 2026 16:08
@thaJeztah

Copy link
Copy Markdown
Member

ugh; missed two lines in my rebase because I dropped an intermediate commit

@ctalledo

Copy link
Copy Markdown
Contributor Author

Superseded by #45, which combines #24, #25 and #26 into a single PR rebased onto latest main (incorporating the review feedback from here). Closing in favor of that.

@ctalledo ctalledo closed this Jul 15, 2026
ctalledo added a commit to ctalledo/go-archive that referenced this pull request Jul 15, 2026
createImpliedDirectories processed the tar header name with
os.PathSeparator and filepath, which is wrong on Windows (tar names
always use forward slashes); after the path.Clean normalisation the
trailing-separator guard was also effectively always true.

Detect directory entries via hdr.Typeflag != tar.TypeDir instead of a
trailing "/", derive the parent with path.Dir on a slash-trimmed name,
and split/join parent components with "/" rather than os.PathSeparator.

Addresses review feedback from thaJeztah on moby#24.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
ctalledo added a commit to ctalledo/go-archive that referenced this pull request Jul 15, 2026
Combines the tar path-traversal hardening (previously split across
moby/go-archive moby#24, moby#25 and moby#26) into one change on current main.
Addresses ART-224 and the cluster of externally reported tar-extraction
breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

- Reject traversal entries instead of clamping them: normalize hdr.Name
  with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names
  via filepath.IsLocal, in both Unpack and UnpackLayer.
- Bound extraction with os.Root (openat-based); create symlinks with
  root.Symlink (target stored verbatim, so absolute targets are kept) and
  hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check.
- Cache the most recent parent directory fd (dirCache) so consecutive
  entries in the same directory use *at(2) syscalls, amortizing os.Root's
  per-call path re-evaluation.
- Resolve symlink components with fsRootPath, a straight fork of
  containerd/continuity fs.RootPath (path.go + path_test.go), un-exported
  and trimmed to the functions used, to ease upstream sync.
- tar header names are POSIX; convert to native paths with
  filepath.FromSlash at each os.Root / filesystem boundary, and skip
  entries whose name or hardlink target Windows cannot represent (":", "\").

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
Combines the tar path-traversal hardening (previously split across
moby/go-archive moby#24, moby#25 and moby#26) into one change on current main.
Addresses ART-224 and the cluster of externally reported tar-extraction
breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

- Reject traversal entries instead of clamping them: normalize hdr.Name
  with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names
  via filepath.IsLocal, in both Unpack and UnpackLayer.
- Bound extraction with os.Root (openat-based); create symlinks with
  root.Symlink (target stored verbatim, so absolute targets are kept) and
  hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check.
- Cache the most recent parent directory fd (dirCache) so consecutive
  entries in the same directory use *at(2) syscalls, amortizing os.Root's
  per-call path re-evaluation.
- Resolve symlink components with fsRootPath, a straight fork of
  containerd/continuity fs.RootPath (path.go + path_test.go), un-exported
  and trimmed to the functions used, to ease upstream sync.
- tar header names are POSIX; convert to native paths with
  filepath.FromSlash at each os.Root / filesystem boundary, and skip
  entries whose name or hardlink target Windows cannot represent (":", "\").

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
Combines the tar path-traversal hardening (previously split across
moby/go-archive moby#24, moby#25 and moby#26) into one change on current main.
Addresses ART-224 and the cluster of externally reported tar-extraction
breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

- Reject traversal entries instead of clamping them: normalize hdr.Name
  with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names
  via filepath.IsLocal, in both Unpack and UnpackLayer.
- Bound extraction with os.Root (openat-based); create symlinks with
  root.Symlink (target stored verbatim, so absolute targets are kept) and
  hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check.
- Cache the most recent parent directory fd (dirCache) so consecutive
  entries in the same directory use *at(2) syscalls, amortizing os.Root's
  per-call path re-evaluation.
- Resolve symlink components with fsRootPath, a straight fork of
  containerd/continuity fs.RootPath (path.go + path_test.go), un-exported
  and trimmed to the functions used, to ease upstream sync.
- tar header names are POSIX; convert to native paths with
  filepath.FromSlash at each os.Root / filesystem boundary, and skip
  entries whose name or hardlink target Windows cannot represent (":", "\").

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
thaJeztah pushed a commit to ctalledo/go-archive that referenced this pull request Jul 16, 2026
Combines the tar path-traversal hardening (previously split across
moby/go-archive moby#24, moby#25 and moby#26) into one change on current main.
Addresses ART-224 and the cluster of externally reported tar-extraction
breakouts (Windows BuildKit ADD/build, and docker cp on all platforms).

- Reject traversal entries instead of clamping them: normalize hdr.Name
  with path.Clean(strings.TrimLeft(name, "/")) and reject non-local names
  via filepath.IsLocal, in both Unpack and UnpackLayer.
- Bound extraction with os.Root (openat-based); create symlinks with
  root.Symlink (target stored verbatim, so absolute targets are kept) and
  hardlinks with root.Link plus a filepath.IsLocal defence-in-depth check.
- Cache the most recent parent directory fd (dirCache) so consecutive
  entries in the same directory use *at(2) syscalls, amortizing os.Root's
  per-call path re-evaluation.
- Resolve symlink components with fsRootPath, a straight fork of
  containerd/continuity fs.RootPath (path.go + path_test.go), un-exported
  and trimmed to the functions used, to ease upstream sync.
- tar header names are POSIX; convert to native paths with
  filepath.FromSlash at each os.Root / filesystem boundary, and skip
  entries whose name or hardlink target Windows cannot represent (":", "\").

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>
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.

5 participants