@@ -25,7 +25,9 @@ import (
2525 "io/fs"
2626 "os"
2727 "path/filepath"
28+ "sort"
2829 "strings"
30+ "time"
2931)
3032
3133func TarPack (srcDirPath string , dstPath string , enableCompression bool ) error {
@@ -142,6 +144,17 @@ func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
142144 if err != nil {
143145 return fmt .Errorf ("normalizing archive destination path: %w" , err )
144146 }
147+ // Ensure destination exists before resolving, so EvalSymlinks works on all platforms.
148+ if mkErr := os .MkdirAll (dstDirPath , 0755 ); mkErr != nil {
149+ return fmt .Errorf ("creating destination directory: %w" , mkErr )
150+ }
151+ // Resolve symlinks in dstDirPath itself so containment checks work correctly
152+ // on platforms where temp paths are symlinks (e.g., macOS /tmp -> /private/tmp)
153+ // or short path names (e.g., Windows RUNNER~1 -> runneradmin).
154+ dstDirPath , err = filepath .EvalSymlinks (dstDirPath )
155+ if err != nil {
156+ return fmt .Errorf ("resolving destination path: %w" , err )
157+ }
145158
146159 tarFile , err := os .Open (srcPath )
147160 if err != nil {
@@ -167,6 +180,15 @@ func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
167180
168181 tarReader := tar .NewReader (tarDst )
169182
183+ // Collect directory timestamps to restore after all files are written,
184+ // because creating files inside a directory updates the directory's mtime.
185+ type dirTimestamp struct {
186+ path string
187+ modTime time.Time
188+ accTime time.Time
189+ }
190+ var dirTimestamps []dirTimestamp
191+
170192 for {
171193 var tarHeader * tar.Header
172194 tarHeader , err = tarReader .Next ()
@@ -181,13 +203,38 @@ func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
181203
182204 filePath := filepath .Join (dstDirPath , tarHeader .Name )
183205
184- // protect against "Zip Slip"
185- if ! strings .HasPrefix (filePath , dstDirPath ) {
186- // mimic standard error, which will be returned in future versions of Go by default
187- // more info can be found by "tarinsecurepath" variable name
206+ // Robust containment check: use filepath.Rel to prevent prefix collisions
207+ // (e.g., dstDirPath="/tmp/out" vs filePath="/tmp/out2/...")
208+ var rel string
209+ rel , err = filepath .Rel (dstDirPath , filePath )
210+ if err != nil || rel == ".." || strings .HasPrefix (rel , ".." + string (os .PathSeparator )) || filepath .IsAbs (rel ) {
188211 return tar .ErrInsecurePath
189212 }
190213
214+ // Symlink-traversal guard: verify that the real (resolved) parent directory
215+ // of filePath is still within dstDirPath. This catches cases where a prior
216+ // symlink entry created a link inside dstDirPath that points outside, and a
217+ // subsequent entry tries to write through that symlink (e.g., symlink "a" -> /etc
218+ // followed by entry "a/passwd").
219+ // For directories, check filepath.Dir(filePath) — the existing parent — so that
220+ // a symlink "a" followed by "a/b" is caught before MkdirAll creates "b" outside.
221+ parentDir := filepath .Dir (filePath )
222+ if parentDir != dstDirPath && filePath != dstDirPath {
223+ // Walk up to the nearest existing ancestor to handle cases where
224+ // parentDir doesn't exist yet but an ancestor symlink escapes.
225+ checkDir := parentDir
226+ for checkDir != dstDirPath {
227+ if realDir , evalErr := filepath .EvalSymlinks (checkDir ); evalErr == nil {
228+ realDirRel , relErr := filepath .Rel (dstDirPath , realDir )
229+ if relErr != nil || realDirRel == ".." || strings .HasPrefix (realDirRel , ".." + string (os .PathSeparator )) || filepath .IsAbs (realDirRel ) {
230+ return tar .ErrInsecurePath
231+ }
232+ break
233+ }
234+ checkDir = filepath .Dir (checkDir )
235+ }
236+ }
237+
191238 fileDirPath := filePath
192239 if ! fileInfo .Mode ().IsDir () {
193240 fileDirPath = filepath .Dir (fileDirPath )
@@ -198,6 +245,21 @@ func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
198245 }
199246
200247 if fileInfo .Mode ().IsDir () {
248+ // Remove any existing symlink at filePath to prevent MkdirAll
249+ // and later Chtimes from following it outside dstDirPath.
250+ if existing , lErr := os .Lstat (filePath ); lErr == nil && existing .Mode ()& fs .ModeSymlink != 0 {
251+ if err = os .Remove (filePath ); err != nil {
252+ return fmt .Errorf ("removing symlink before mkdir %s: %w" , filePath , err )
253+ }
254+ if err = os .MkdirAll (filePath , 0755 ); err != nil {
255+ return fmt .Errorf ("making directory %s: %w" , filePath , err )
256+ }
257+ }
258+ dirTimestamps = append (dirTimestamps , dirTimestamp {
259+ path : filePath ,
260+ modTime : tarHeader .ModTime ,
261+ accTime : tarHeader .AccessTime ,
262+ })
201263 continue
202264 }
203265
@@ -208,21 +270,73 @@ func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
208270 continue
209271 }
210272
211- if err = tarUnpackFile (filePath , tarReader , fileInfo ); err != nil {
273+ // Remove any existing symlink at filePath to prevent following it
274+ // when writing a regular file (a malicious tar could plant a symlink
275+ // then overwrite it with a regular file entry targeting outside dst).
276+ if existing , lErr := os .Lstat (filePath ); lErr == nil && existing .Mode ()& fs .ModeSymlink != 0 {
277+ if err = os .Remove (filePath ); err != nil {
278+ return fmt .Errorf ("removing symlink before file write %s: %w" , filePath , err )
279+ }
280+ }
281+
282+ if err = tarUnpackFile (filePath , tarReader , tarHeader ); err != nil {
212283 return fmt .Errorf ("unpacking file %s: %w" , filePath , err )
213284 }
214285 }
286+
287+ // Restore directory timestamps deepest-first so that restoring a parent's
288+ // mtime is not undone by a subsequent Chtimes on a child directory.
289+ sort .Slice (dirTimestamps , func (i , j int ) bool {
290+ return strings .Count (dirTimestamps [i ].path , string (os .PathSeparator )) >
291+ strings .Count (dirTimestamps [j ].path , string (os .PathSeparator ))
292+ })
293+ for _ , dt := range dirTimestamps {
294+ if dt .modTime .IsZero () {
295+ continue
296+ }
297+ accTime := dt .accTime
298+ if accTime .IsZero () {
299+ accTime = dt .modTime
300+ }
301+ if err := os .Chtimes (dt .path , accTime , dt .modTime ); err != nil {
302+ return fmt .Errorf ("restoring timestamps for directory %s: %w" , dt .path , err )
303+ }
304+ }
305+
306+ return nil
307+ }
308+
309+ func tarUnpackFile (dstFileName string , src io.Reader , header * tar.Header ) (err error ) {
310+ srcFileInfo := header .FileInfo ()
311+
312+ if err = tarWriteFile (dstFileName , src , srcFileInfo ); err != nil {
313+ return err
314+ }
315+
316+ // Restore original timestamps from tar header after the file is closed,
317+ // since some platforms (e.g. Windows) cannot change timestamps on open files.
318+ if header .ModTime .IsZero () {
319+ return nil
320+ }
321+ accTime := header .AccessTime
322+ if accTime .IsZero () {
323+ accTime = header .ModTime
324+ }
325+ if err = os .Chtimes (dstFileName , accTime , header .ModTime ); err != nil {
326+ return fmt .Errorf ("restoring timestamps for %s: %w" , dstFileName , err )
327+ }
328+
215329 return nil
216330}
217331
218- func tarUnpackFile (dstFileName string , src io.Reader , srcFileInfo fs.FileInfo ) (err error ) {
332+ func tarWriteFile (dstFileName string , src io.Reader , srcFileInfo fs.FileInfo ) (err error ) {
219333 var dstFile * os.File
220- dstFile , err = os .OpenFile (dstFileName , os .O_RDWR | os .O_CREATE | os .O_TRUNC , srcFileInfo .Mode ().Perm ())
334+ dstFile , err = os .OpenFile (dstFileName , os .O_WRONLY | os .O_CREATE | os .O_TRUNC , srcFileInfo .Mode ().Perm ())
221335 if err != nil {
222336 return fmt .Errorf ("opening destination file %s: %w" , dstFileName , err )
223337 }
224338 defer func () {
225- err = errors .Join (err , closeAndWrapErr (dstFile , "closing destination file %s: %w" , dstFile ))
339+ err = errors .Join (err , closeAndWrapErr (dstFile , "closing destination file %s: %w" , dstFileName ))
226340 }()
227341
228342 n , err := io .Copy (dstFile , src )
0 commit comments