@@ -23,6 +23,7 @@ import (
2323 "io"
2424 "io/fs"
2525 "os"
26+ "path"
2627 "path/filepath"
2728 "strings"
2829
@@ -69,29 +70,64 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt
6970 return errdefs .ErrNotImplemented
7071}
7172
73+ // rootRel converts a path expressed in the container's view (which
74+ // may be absolute or contain parent-directory components) into a path
75+ // usable with *os.Root operations. Leading "/" is stripped after
76+ // cleaning, and any leading "../" sequences are collapsed by Clean,
77+ // guaranteeing the result resolves within the root. An empty result
78+ // is mapped to ".", which os.Root treats as the root itself.
79+ func rootRel (p string ) string {
80+ p = strings .TrimPrefix (path .Clean ("/" + p ), "/" )
81+ if p == "" {
82+ return "."
83+ }
84+ return p
85+ }
86+
7287// writePath creates a tar archive from the given path within rootfs
7388// and writes it to w. When noWalk is true and path is a directory,
74- // only the directory entry itself is included without walking into it.
75- func writePath (rootfs , path string , w io.Writer , mediaType string , noWalk bool ) error {
89+ // only the directory entry itself is included without walking into
90+ // it.
91+ //
92+ // All filesystem accesses are anchored to rootfs through *os.Root,
93+ // so symlink resolution cannot escape the rootfs even if the
94+ // container concurrently mutates its own filesystem.
95+ func writePath (rootfs , src string , w io.Writer , mediaType string , noWalk bool ) error {
7696 if mediaType != mediaTypeTar {
7797 return fmt .Errorf ("unsupported media type %q: %w" , mediaType , errdefs .ErrNotImplemented )
7898 }
7999
80- srcPath := filepath .Join (rootfs , filepath .Clean ("/" + path ))
100+ root , err := os .OpenRoot (rootfs )
101+ if err != nil {
102+ return fmt .Errorf ("failed to open rootfs: %w" , err )
103+ }
104+ defer root .Close ()
105+
106+ relPath := rootRel (src )
81107
82- fi , err := os .Lstat (srcPath )
108+ fi , err := root .Lstat (relPath )
83109 if err != nil {
84- return fmt .Errorf ("failed to stat %s: %w" , path , err )
110+ return fmt .Errorf ("failed to stat %s: %w" , src , err )
85111 }
86112
113+ // The top-level entry name is the basename of the requested
114+ // path. When the caller asks for the whole filesystem (path "/"),
115+ // relPath is "." and baseName is "."; child entries then drop
116+ // the leading "./" via path.Join, so the tar contains
117+ // "bin/sh" rather than leaking the host bundle's directory name.
118+ baseName := path .Base (relPath )
119+
87120 tw := tar .NewWriter (w )
88- defer tw .Close ()
89121
90122 if ! fi .IsDir () || noWalk {
91- return writeTarEntry (tw , srcPath , fi , filepath .Base (srcPath ))
123+ if err := writeTarEntry (root , tw , relPath , fi , baseName ); err != nil {
124+ tw .Close ()
125+ return err
126+ }
127+ return tw .Close ()
92128 }
93129
94- return filepath .WalkDir (srcPath , func (filePath string , d fs.DirEntry , err error ) error {
130+ if err := fs .WalkDir (root . FS (), relPath , func (walkPath string , d fs.DirEntry , err error ) error {
95131 if err != nil {
96132 return err
97133 }
@@ -100,31 +136,36 @@ func writePath(rootfs, path string, w io.Writer, mediaType string, noWalk bool)
100136 return err
101137 }
102138
103- // Compute relative path for the tar header
104- rel , err := filepath . Rel ( srcPath , filePath )
105- if err != nil {
106- return err
107- }
108- if rel == ". " {
109- rel = filepath . Base ( srcPath )
139+ // walkPath is always slash-separated (fs.FS contract) and
140+ // rooted at relPath. Strip the prefix to get the entry's
141+ // path within the walk.
142+ rel := strings . TrimPrefix ( strings . TrimPrefix ( walkPath , relPath ), "/" )
143+ var name string
144+ if rel == "" {
145+ name = baseName
110146 } else {
111- rel = filepath .Join (filepath . Base ( srcPath ) , rel )
147+ name = path .Join (baseName , rel )
112148 }
113149
114- return writeTarEntry (tw , filePath , info , rel )
115- })
150+ return writeTarEntry (root , tw , walkPath , info , name )
151+ }); err != nil {
152+ tw .Close ()
153+ return err
154+ }
155+ return tw .Close ()
116156}
117157
118- func writeTarEntry (tw * tar.Writer , filePath string , fi os.FileInfo , name string ) error {
158+ // writeTarEntry writes a single tar entry. srcPath is interpreted
159+ // relative to root, so symlink resolution cannot escape the rootfs.
160+ func writeTarEntry (root * os.Root , tw * tar.Writer , srcPath string , fi os.FileInfo , name string ) error {
119161 header , err := tar .FileInfoHeader (fi , "" )
120162 if err != nil {
121163 return err
122164 }
123165 header .Name = name
124166
125- // Resolve symlink target
126167 if fi .Mode ()& os .ModeSymlink != 0 {
127- link , err := os .Readlink (filePath )
168+ link , err := root .Readlink (srcPath )
128169 if err != nil {
129170 return err
130171 }
@@ -136,7 +177,7 @@ func writeTarEntry(tw *tar.Writer, filePath string, fi os.FileInfo, name string)
136177 }
137178
138179 if fi .Mode ().IsRegular () {
139- f , err := os .Open (filePath )
180+ f , err := root .Open (srcPath )
140181 if err != nil {
141182 return err
142183 }
@@ -149,15 +190,40 @@ func writeTarEntry(tw *tar.Writer, filePath string, fi os.FileInfo, name string)
149190 return nil
150191}
151192
152- // readPath reads a tar archive from r and extracts it to the given path
193+ // readPath reads a tar archive from r and extracts it under path
153194// within rootfs. When preserveOwnership is true, extracted files have
154195// their UID/GID set from the tar headers.
155- func readPath (r io.Reader , rootfs , path , mediaType string , preserveOwnership bool ) error {
196+ //
197+ // The destination directory is opened as a sub-*os.Root so the
198+ // destination boundary is enforced by os.Root rather than by lexical
199+ // path checks. Pre-existing symlinks within the rootfs, symlinks
200+ // created by earlier entries in the same archive, absolute symlink
201+ // targets, and tar entry names containing "../" all resolve within
202+ // the destination's sub-root and cannot redirect writes outside it.
203+ func readPath (r io.Reader , rootfs , dstPath , mediaType string , preserveOwnership bool ) error {
156204 if mediaType != mediaTypeTar {
157205 return fmt .Errorf ("unsupported media type %q: %w" , mediaType , errdefs .ErrNotImplemented )
158206 }
159207
160- dstPath := filepath .Join (rootfs , filepath .Clean ("/" + path ))
208+ root , err := os .OpenRoot (rootfs )
209+ if err != nil {
210+ return fmt .Errorf ("failed to open rootfs: %w" , err )
211+ }
212+ defer root .Close ()
213+
214+ relDst := rootRel (dstPath )
215+
216+ dst := root
217+ if relDst != "." {
218+ if err := root .MkdirAll (relDst , 0755 ); err != nil {
219+ return fmt .Errorf ("failed to create destination: %w" , err )
220+ }
221+ dst , err = root .OpenRoot (relDst )
222+ if err != nil {
223+ return fmt .Errorf ("failed to open destination: %w" , err )
224+ }
225+ defer dst .Close ()
226+ }
161227
162228 tr := tar .NewReader (r )
163229 for {
@@ -169,30 +235,33 @@ func readPath(r io.Reader, rootfs, path, mediaType string, preserveOwnership boo
169235 return fmt .Errorf ("failed to read tar header: %w" , err )
170236 }
171237
172- target := filepath .Join (dstPath , filepath .Clean ("/" + header .Name ))
173-
174- // Ensure the target is within the destination directory
175- if ! strings .HasPrefix (target , filepath .Clean (dstPath )+ string (os .PathSeparator )) && target != filepath .Clean (dstPath ) {
176- return fmt .Errorf ("tar entry %q would escape destination" , header .Name )
238+ // Clean the entry name relative to "/" so any "../" sequences
239+ // collapse before we hand the path to dst. dst itself enforces
240+ // the destination boundary.
241+ entryName := strings .TrimPrefix (path .Clean ("/" + header .Name ), "/" )
242+ if entryName == "" {
243+ // Names that resolve to the destination itself (e.g. "."
244+ // or "/") have nothing to extract.
245+ continue
177246 }
178247
179- if err := extractTarEntry (target , header , tr , preserveOwnership ); err != nil {
248+ if err := extractTarEntry (dst , entryName , header , tr , preserveOwnership ); err != nil {
180249 return err
181250 }
182251 }
183252}
184253
185- func extractTarEntry (target string , header * tar.Header , r io.Reader , preserveOwnership bool ) error {
254+ func extractTarEntry (dst * os. Root , target string , header * tar.Header , r io.Reader , preserveOwnership bool ) error {
186255 switch header .Typeflag {
187256 case tar .TypeDir :
188- if err := os .MkdirAll (target , os .FileMode (header .Mode )); err != nil {
257+ if err := dst .MkdirAll (target , os .FileMode (header .Mode )); err != nil {
189258 return err
190259 }
191260 case tar .TypeReg :
192- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
261+ if err := dst .MkdirAll (path .Dir (target ), 0755 ); err != nil {
193262 return err
194263 }
195- f , err := os .OpenFile (target , os .O_CREATE | os .O_WRONLY | os .O_TRUNC , os .FileMode (header .Mode ))
264+ f , err := dst .OpenFile (target , os .O_CREATE | os .O_WRONLY | os .O_TRUNC , os .FileMode (header .Mode ))
196265 if err != nil {
197266 return err
198267 }
@@ -204,23 +273,31 @@ func extractTarEntry(target string, header *tar.Header, r io.Reader, preserveOwn
204273 return err
205274 }
206275 case tar .TypeSymlink :
207- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
276+ if err := dst .MkdirAll (path .Dir (target ), 0755 ); err != nil {
208277 return err
209278 }
210- if err := os .Symlink (header .Linkname , target ); err != nil {
279+ // The symlink target string is stored verbatim. When later
280+ // traversed through dst it will be resolved within the
281+ // destination sub-root, so an absolute or "../"-laden target
282+ // cannot redirect reads or writes outside it.
283+ if err := dst .Symlink (header .Linkname , target ); err != nil {
211284 return err
212285 }
213286 case tar .TypeLink :
214- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
287+ if err := dst .MkdirAll (path .Dir (target ), 0755 ); err != nil {
215288 return err
216289 }
217- if err := os .Link (header .Linkname , target ); err != nil {
290+ // Hardlink source names another entry in the same archive.
291+ // Clean it the same way as the entry name; dst.Link enforces
292+ // that both ends remain inside the destination sub-root.
293+ linkSrc := strings .TrimPrefix (path .Clean ("/" + header .Linkname ), "/" )
294+ if err := dst .Link (linkSrc , target ); err != nil {
218295 return err
219296 }
220297 }
221298
222299 if preserveOwnership {
223- if err := os .Lchown (target , header .Uid , header .Gid ); err != nil {
300+ if err := dst .Lchown (target , header .Uid , header .Gid ); err != nil {
224301 return fmt .Errorf ("failed to chown %s: %w" , target , err )
225302 }
226303 }
0 commit comments