@@ -69,29 +69,61 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt
6969 return errdefs .ErrNotImplemented
7070}
7171
72+ // rootRel converts a path expressed in the container's view (which
73+ // may be absolute or contain parent-directory components) into a path
74+ // usable with *os.Root operations. Leading "/" is stripped after
75+ // cleaning, and any leading "../" sequences are collapsed by Clean,
76+ // guaranteeing the result resolves within the root. An empty result
77+ // is mapped to ".", which os.Root treats as the root itself.
78+ func rootRel (p string ) string {
79+ p = strings .TrimPrefix (filepath .Clean ("/" + p ), "/" )
80+ if p == "" {
81+ return "."
82+ }
83+ return p
84+ }
85+
7286// writePath creates a tar archive from the given path within rootfs
7387// 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.
88+ // only the directory entry itself is included without walking into
89+ // it.
90+ //
91+ // All filesystem accesses are anchored to rootfs through *os.Root,
92+ // so symlink resolution cannot escape the rootfs even if the
93+ // container concurrently mutates its own filesystem.
7594func writePath (rootfs , path string , w io.Writer , mediaType string , noWalk bool ) error {
7695 if mediaType != mediaTypeTar {
7796 return fmt .Errorf ("unsupported media type %q: %w" , mediaType , errdefs .ErrNotImplemented )
7897 }
7998
80- srcPath := filepath .Join (rootfs , filepath .Clean ("/" + path ))
99+ root , err := os .OpenRoot (rootfs )
100+ if err != nil {
101+ return fmt .Errorf ("failed to open rootfs: %w" , err )
102+ }
103+ defer root .Close ()
104+
105+ relPath := rootRel (path )
81106
82- fi , err := os .Lstat (srcPath )
107+ fi , err := root .Lstat (relPath )
83108 if err != nil {
84109 return fmt .Errorf ("failed to stat %s: %w" , path , err )
85110 }
86111
112+ // The top-level entry name is the basename of the requested
113+ // path. When the caller asks for the whole filesystem (path "/"),
114+ // relPath is "." and baseName is "."; child entries then drop
115+ // the leading "./" via filepath.Join, so the tar contains
116+ // "bin/sh" rather than leaking the host bundle's directory name.
117+ baseName := filepath .Base (relPath )
118+
87119 tw := tar .NewWriter (w )
88120 defer tw .Close ()
89121
90122 if ! fi .IsDir () || noWalk {
91- return writeTarEntry (tw , srcPath , fi , filepath . Base ( srcPath ) )
123+ return writeTarEntry (root , tw , relPath , fi , baseName )
92124 }
93125
94- return filepath .WalkDir (srcPath , func (filePath string , d fs.DirEntry , err error ) error {
126+ return fs .WalkDir (root . FS (), relPath , func (walkPath string , d fs.DirEntry , err error ) error {
95127 if err != nil {
96128 return err
97129 }
@@ -100,31 +132,32 @@ func writePath(rootfs, path string, w io.Writer, mediaType string, noWalk bool)
100132 return err
101133 }
102134
103- // Compute relative path for the tar header
104- rel , err := filepath .Rel (srcPath , filePath )
135+ rel , err := filepath .Rel (relPath , walkPath )
105136 if err != nil {
106137 return err
107138 }
139+ var name string
108140 if rel == "." {
109- rel = filepath . Base ( srcPath )
141+ name = baseName
110142 } else {
111- rel = filepath .Join (filepath . Base ( srcPath ) , rel )
143+ name = filepath .Join (baseName , rel )
112144 }
113145
114- return writeTarEntry (tw , filePath , info , rel )
146+ return writeTarEntry (root , tw , walkPath , info , name )
115147 })
116148}
117149
118- func writeTarEntry (tw * tar.Writer , filePath string , fi os.FileInfo , name string ) error {
150+ // writeTarEntry writes a single tar entry. srcPath is interpreted
151+ // relative to root, so symlink resolution cannot escape the rootfs.
152+ func writeTarEntry (root * os.Root , tw * tar.Writer , srcPath string , fi os.FileInfo , name string ) error {
119153 header , err := tar .FileInfoHeader (fi , "" )
120154 if err != nil {
121155 return err
122156 }
123157 header .Name = name
124158
125- // Resolve symlink target
126159 if fi .Mode ()& os .ModeSymlink != 0 {
127- link , err := os .Readlink (filePath )
160+ link , err := root .Readlink (srcPath )
128161 if err != nil {
129162 return err
130163 }
@@ -136,7 +169,7 @@ func writeTarEntry(tw *tar.Writer, filePath string, fi os.FileInfo, name string)
136169 }
137170
138171 if fi .Mode ().IsRegular () {
139- f , err := os .Open (filePath )
172+ f , err := root .Open (srcPath )
140173 if err != nil {
141174 return err
142175 }
@@ -149,15 +182,40 @@ func writeTarEntry(tw *tar.Writer, filePath string, fi os.FileInfo, name string)
149182 return nil
150183}
151184
152- // readPath reads a tar archive from r and extracts it to the given path
185+ // readPath reads a tar archive from r and extracts it under path
153186// within rootfs. When preserveOwnership is true, extracted files have
154187// their UID/GID set from the tar headers.
188+ //
189+ // The destination directory is opened as a sub-*os.Root so the
190+ // destination boundary is enforced by os.Root rather than by lexical
191+ // path checks. Pre-existing symlinks within the rootfs, symlinks
192+ // created by earlier entries in the same archive, absolute symlink
193+ // targets, and tar entry names containing "../" all resolve within
194+ // the destination's sub-root and cannot redirect writes outside it.
155195func readPath (r io.Reader , rootfs , path , mediaType string , preserveOwnership bool ) error {
156196 if mediaType != mediaTypeTar {
157197 return fmt .Errorf ("unsupported media type %q: %w" , mediaType , errdefs .ErrNotImplemented )
158198 }
159199
160- dstPath := filepath .Join (rootfs , filepath .Clean ("/" + path ))
200+ root , err := os .OpenRoot (rootfs )
201+ if err != nil {
202+ return fmt .Errorf ("failed to open rootfs: %w" , err )
203+ }
204+ defer root .Close ()
205+
206+ relDst := rootRel (path )
207+
208+ dst := root
209+ if relDst != "." {
210+ if err := root .MkdirAll (relDst , 0755 ); err != nil {
211+ return fmt .Errorf ("failed to create destination: %w" , err )
212+ }
213+ dst , err = root .OpenRoot (relDst )
214+ if err != nil {
215+ return fmt .Errorf ("failed to open destination: %w" , err )
216+ }
217+ defer dst .Close ()
218+ }
161219
162220 tr := tar .NewReader (r )
163221 for {
@@ -169,30 +227,33 @@ func readPath(r io.Reader, rootfs, path, mediaType string, preserveOwnership boo
169227 return fmt .Errorf ("failed to read tar header: %w" , err )
170228 }
171229
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 )
230+ // Clean the entry name relative to "/" so any "../" sequences
231+ // collapse before we hand the path to dst. dst itself enforces
232+ // the destination boundary.
233+ entryName := strings .TrimPrefix (filepath .Clean ("/" + header .Name ), "/" )
234+ if entryName == "" {
235+ // Names that resolve to the destination itself (e.g. "."
236+ // or "/") have nothing to extract.
237+ continue
177238 }
178239
179- if err := extractTarEntry (target , header , tr , preserveOwnership ); err != nil {
240+ if err := extractTarEntry (dst , entryName , header , tr , preserveOwnership ); err != nil {
180241 return err
181242 }
182243 }
183244}
184245
185- func extractTarEntry (target string , header * tar.Header , r io.Reader , preserveOwnership bool ) error {
246+ func extractTarEntry (dst * os. Root , target string , header * tar.Header , r io.Reader , preserveOwnership bool ) error {
186247 switch header .Typeflag {
187248 case tar .TypeDir :
188- if err := os .MkdirAll (target , os .FileMode (header .Mode )); err != nil {
249+ if err := dst .MkdirAll (target , os .FileMode (header .Mode )); err != nil {
189250 return err
190251 }
191252 case tar .TypeReg :
192- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
253+ if err := dst .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
193254 return err
194255 }
195- f , err := os .OpenFile (target , os .O_CREATE | os .O_WRONLY | os .O_TRUNC , os .FileMode (header .Mode ))
256+ f , err := dst .OpenFile (target , os .O_CREATE | os .O_WRONLY | os .O_TRUNC , os .FileMode (header .Mode ))
196257 if err != nil {
197258 return err
198259 }
@@ -204,23 +265,31 @@ func extractTarEntry(target string, header *tar.Header, r io.Reader, preserveOwn
204265 return err
205266 }
206267 case tar .TypeSymlink :
207- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
268+ if err := dst .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
208269 return err
209270 }
210- if err := os .Symlink (header .Linkname , target ); err != nil {
271+ // The symlink target string is stored verbatim. When later
272+ // traversed through dst it will be resolved within the
273+ // destination sub-root, so an absolute or "../"-laden target
274+ // cannot redirect reads or writes outside it.
275+ if err := dst .Symlink (header .Linkname , target ); err != nil {
211276 return err
212277 }
213278 case tar .TypeLink :
214- if err := os .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
279+ if err := dst .MkdirAll (filepath .Dir (target ), 0755 ); err != nil {
215280 return err
216281 }
217- if err := os .Link (header .Linkname , target ); err != nil {
282+ // Hardlink source names another entry in the same archive.
283+ // Clean it the same way as the entry name; dst.Link enforces
284+ // that both ends remain inside the destination sub-root.
285+ linkSrc := strings .TrimPrefix (filepath .Clean ("/" + header .Linkname ), "/" )
286+ if err := dst .Link (linkSrc , target ); err != nil {
218287 return err
219288 }
220289 }
221290
222291 if preserveOwnership {
223- if err := os .Lchown (target , header .Uid , header .Gid ); err != nil {
292+ if err := dst .Lchown (target , header .Uid , header .Gid ); err != nil {
224293 return fmt .Errorf ("failed to chown %s: %w" , target , err )
225294 }
226295 }
0 commit comments