archive: replace static path check with symlink-aware safeResolve - #24
archive: replace static path check with symlink-aware safeResolve#24ctalledo wants to merge 2 commits into
Conversation
d2b946f to
28dffd9
Compare
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
52a850e to
89be728
Compare
51f428c to
96cbab5
Compare
96cbab5 to
45ebb8e
Compare
|
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. |
| 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), "/") |
There was a problem hiding this comment.
What was the reason for changing from filepath.Clean?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
| // ("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) { |
There was a problem hiding this comment.
Outside of the difference in full/relative path args, is this implementation better than symlink.FollowSymlinkInScope ?
There was a problem hiding this comment.
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.
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>
| // 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 | ||
| } | ||
|
|
There was a problem hiding this comment.
This should also get the same hardening as hardlink?
There was a problem hiding this comment.
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.
|
@tonistiigi PTAL |
| // inode itself, not its target). | ||
| targetDir, err := safeResolve(extractDir, filepath.Dir(hdr.Linkname)) |
There was a problem hiding this comment.
We should use path, not filepath when processing tar headers, because they're expected to always follow unix-semantics (forward slashes)
| // 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)) |
| if err != nil { | ||
| return err | ||
| } | ||
| targetPath := filepath.Join(targetDir, filepath.Base(hdr.Linkname)) |
There was a problem hiding this comment.
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))| if !strings.HasSuffix(hdr.Name, string(os.PathSeparator)) { | ||
| parent := filepath.Dir(hdr.Name) | ||
| parentPath := filepath.Join(dest, parent) |
There was a problem hiding this comment.
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, "/"))
// ...
}| // Normalise the input to forward slashes so that all internal path | ||
| // operations have consistent semantics on every platform. | ||
| p = filepath.ToSlash(p) |
There was a problem hiding this comment.
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) // "."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>
c7f83f1 to
1c897b6
Compare
|
ugh; missed two lines in my rebase because I dropped an intermediate commit |
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>
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>
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>
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>
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>
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.
UnpackandUnpackLayervalidated entrypaths using
filepath.Join+filepath.Rel, which does not followsymlinks on disk. A two-hop chain (an in-root symlink to
..followedby 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
chrootarchivewrapping extraction inchroot(2). On platforms without chroot support (Windows) and forcallers that bypass
chrootarchive(e.g. BuildKitADD --unpack),the extraction root was not enforced.
This PR introduces
safeResolve(ported fromcontainerd/continuity/fs.RootPath), which walks each path componentwith
os.Lstatand resolves symlinks within the extraction root,bounding any targets that would escape back inside it.
Applied to:
Unpack: main path computation and deferred directorychtimesloopUnpackLayer: samecreateTarFileTypeLink: hardlink target resolutionTest plan
go test ./...passesTestUntarSymlinkBreakoutfails on the pre-fix code (confirmedlocally): a two-hop symlink chain (
inner/go_up -> "..",inner/go_up/escape -> "../victim") bypasses both the entry-namecheck and the existing symlink-target check in
createTarFile, butis blocked by
safeResolve