-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Restore original timestamps when unpacking
The current implementation made no attempts to restore file, directory, or symlink info, including mode and timestamps. I've modified the behavior to make a better attempts at restoring timestamps such that when the slug is hashed by something like hashicorp/tfe's tfe_slug data source, the hash is identical: - For files, the atime and mtime are restored after extraction - For symlinks, only linux and darwin are supported because those are the only platforms that have the lutimes syscall. - For directories, the process of extracting files necessarily modifies the timestamps of the directory itself, so these are tracked in an internal data structure and then all the directories are modified after all files are exracted. This is similar to how tar preserves timestamps on directories. See https://www.gnu.org/software/tar/manual/html_node/Directory-Modification-Times-and-Permissions.html for details I also refactored some of the unpacking rules and restoration methods into a new internal package because of the added complexity of platform dependency and build constraints. This helped to clean up some of the rules governing illegal slugs.
- Loading branch information
Showing
8 changed files
with
457 additions
and
91 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
//go:build darwin || linux | ||
// +build darwin linux | ||
|
||
package unpackinfo | ||
|
||
import ( | ||
"golang.org/x/sys/unix" | ||
) | ||
|
||
// Lchtimes modifies the access and modified timestamps on a target path | ||
// This capability is only available on Linux and Darwin as of now. | ||
func (i UnpackInfo) Lchtimes() error { | ||
return unix.Lutimes(i.Path, []unix.Timeval{ | ||
{Sec: i.OriginalAccessTime.Unix()}, | ||
{Sec: i.OriginalModTime.Unix()}}, | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
//go:build !(linux || darwin) | ||
// +build !linux,!darwin | ||
|
||
package unpackinfo | ||
|
||
import ( | ||
"errors" | ||
) | ||
|
||
// Lchtimes modifies the access and modified timestamps on a target path | ||
// This capability is only available on Linux and Darwin as of now. | ||
func (i UnpackInfo) Lchtimes() error { | ||
return errors.New("Lchtimes is not supported on this platform") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,165 @@ | ||
package unpackinfo | ||
|
||
import ( | ||
"archive/tar" | ||
"errors" | ||
"fmt" | ||
"io/fs" | ||
"os" | ||
"path/filepath" | ||
"runtime" | ||
"strings" | ||
"time" | ||
) | ||
|
||
// UnpackInfo stores information about the file (or directory, or symlink) being | ||
// unpacked. UnpackInfo ensures certain malicious tar files are not unpacked. | ||
// The information can be used later to restore the original permissions | ||
// and timestamps based on the type of entry the info represents. | ||
type UnpackInfo struct { | ||
Path string | ||
OriginalAccessTime time.Time | ||
OriginalModTime time.Time | ||
OriginalMode fs.FileMode | ||
Typeflag byte | ||
} | ||
|
||
// NewUnpackInfo returns an UnpackInfo based on a destination root and a tar header. | ||
// It will return an error if the header represents an illegal symlink extraction | ||
// or if the entry type is not supported by go-slug. | ||
func NewUnpackInfo(dst string, header *tar.Header) (UnpackInfo, error) { | ||
// Get rid of absolute paths. | ||
path := header.Name | ||
|
||
if path[0] == '/' { | ||
path = path[1:] | ||
} | ||
path = filepath.Join(dst, path) | ||
|
||
// Check for paths outside our directory, they are forbidden | ||
target := filepath.Clean(path) | ||
if !strings.HasPrefix(target, dst) { | ||
return UnpackInfo{}, errors.New("invalid filename, traversal with \"..\" outside of current directory") | ||
} | ||
|
||
// Ensure the destination is not through any symlinks. This prevents | ||
// any files from being deployed through symlinks defined in the slug. | ||
// There are malicious cases where this could be used to escape the | ||
// slug's boundaries (zipslip), and any legitimate use is questionable | ||
// and likely indicates a hand-crafted tar file, which we are not in | ||
// the business of supporting here. | ||
// | ||
// The strategy is to Lstat each path component from dst up to the | ||
// immediate parent directory of the file name in the tarball, checking | ||
// the mode on each to ensure we wouldn't be passing through any | ||
// symlinks. | ||
currentPath := dst // Start at the root of the unpacked tarball. | ||
components := strings.Split(header.Name, "/") | ||
|
||
for i := 0; i < len(components)-1; i++ { | ||
currentPath = filepath.Join(currentPath, components[i]) | ||
fi, err := os.Lstat(currentPath) | ||
if os.IsNotExist(err) { | ||
// Parent directory structure is incomplete. Technically this | ||
// means from here upward cannot be a symlink, so we cancel the | ||
// remaining path tests. | ||
break | ||
} | ||
if err != nil { | ||
return UnpackInfo{}, fmt.Errorf("failed to evaluate path %q: %w", header.Name, err) | ||
} | ||
if fi.Mode()&fs.ModeSymlink != 0 { | ||
return UnpackInfo{}, fmt.Errorf("cannot extract %q through symlink", header.Name) | ||
} | ||
} | ||
|
||
result := UnpackInfo{ | ||
Path: path, | ||
OriginalAccessTime: header.AccessTime, | ||
OriginalModTime: header.ModTime, | ||
OriginalMode: header.FileInfo().Mode(), | ||
Typeflag: header.Typeflag, | ||
} | ||
|
||
if !result.IsDirectory() && !result.IsSymlink() && !result.IsRegular() && !result.IsTypeX() { | ||
return UnpackInfo{}, fmt.Errorf("failed creating %q, unsupported file type %c", path, result.Typeflag) | ||
} | ||
|
||
return result, nil | ||
} | ||
|
||
// IsSymlink describes whether the file being unpacked is a symlink | ||
func (i UnpackInfo) IsSymlink() bool { | ||
return i.Typeflag == tar.TypeSymlink | ||
} | ||
|
||
// IsDirectory describes whether the file being unpacked is a directory | ||
func (i UnpackInfo) IsDirectory() bool { | ||
return i.Typeflag == tar.TypeDir | ||
} | ||
|
||
// IsTypeX describes whether the file being unpacked is a special TypeXHeader that can | ||
// be ignored by go-slug | ||
func (i UnpackInfo) IsTypeX() bool { | ||
return i.Typeflag == tar.TypeXGlobalHeader || i.Typeflag == tar.TypeXHeader | ||
} | ||
|
||
// IsRegular describes whether the file being unpacked is a regular file | ||
func (i UnpackInfo) IsRegular() bool { | ||
return i.Typeflag == tar.TypeReg || i.Typeflag == tar.TypeRegA | ||
} | ||
|
||
// CanMaintainSymlinkTimestamps determines whether is is possible to change | ||
// timestamps on symlinks for the the current platform. For regular files | ||
// and directories, attempts are made to restore permissions and timestamps | ||
// after extraction. But for symbolic links, go's cross-platform | ||
// packages (Chmod and Chtimes) are not capable of changing symlink info | ||
// because those methods follow the symlinks. However, a platform-dependent option | ||
// is provided for linux and darwin (see Lchtimes) | ||
func CanMaintainSymlinkTimestamps() bool { | ||
return runtime.GOOS == "linux" || runtime.GOOS == "darwin" | ||
} | ||
|
||
// RestoreInfo changes the file mode and timestamps for the given UnpackInfo data | ||
func (i UnpackInfo) RestoreInfo() error { | ||
switch { | ||
case i.IsDirectory(): | ||
return i.restoreDirectory() | ||
case i.IsSymlink(): | ||
if CanMaintainSymlinkTimestamps() { | ||
return i.restoreSymlink() | ||
} | ||
return nil | ||
default: // Normal file | ||
return i.restoreNormal() | ||
} | ||
} | ||
|
||
func (i UnpackInfo) restoreDirectory() error { | ||
if err := os.Chtimes(i.Path, i.OriginalAccessTime, i.OriginalModTime); err != nil && !os.IsNotExist(err) { | ||
return fmt.Errorf("failed setting times on directory %q: %w", i.Path, err) | ||
} | ||
|
||
if err := os.Chmod(i.Path, i.OriginalMode); err != nil && !os.IsNotExist(err) { | ||
return fmt.Errorf("failed setting permissions on directory %q: %w", i.Path, err) | ||
} | ||
return nil | ||
} | ||
|
||
func (i UnpackInfo) restoreSymlink() error { | ||
if err := i.Lchtimes(); err != nil { | ||
return fmt.Errorf("failed setting times on symlink %q: %w", i.Path, err) | ||
} | ||
return nil | ||
} | ||
|
||
func (i UnpackInfo) restoreNormal() error { | ||
if err := os.Chmod(i.Path, i.OriginalMode); err != nil { | ||
return fmt.Errorf("failed setting permissions on %q: %w", i.Path, err) | ||
} | ||
|
||
if err := os.Chtimes(i.Path, i.OriginalAccessTime, i.OriginalModTime); err != nil { | ||
return fmt.Errorf("failed setting times on %q: %w", i.Path, err) | ||
} | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
package unpackinfo | ||
|
||
import ( | ||
"archive/tar" | ||
"os" | ||
"path" | ||
"strings" | ||
"testing" | ||
"time" | ||
) | ||
|
||
func TestNewUnpackInfo(t *testing.T) { | ||
t.Parallel() | ||
|
||
t.Run("disallow parent traversal", func(t *testing.T) { | ||
_, err := NewUnpackInfo("test", &tar.Header{ | ||
Name: "../off-limits", | ||
Typeflag: tar.TypeSymlink, | ||
}) | ||
|
||
if err == nil { | ||
t.Fatal("expected error, got nil") | ||
} | ||
|
||
expected := "invalid filename, traversal with \"..\"" | ||
if !strings.Contains(err.Error(), expected) { | ||
t.Fatalf("expected error to contain %q, got %q", expected, err) | ||
} | ||
}) | ||
|
||
t.Run("disallow zipslip", func(t *testing.T) { | ||
dst := t.TempDir() | ||
|
||
err := os.Symlink("..", path.Join(dst, "subdir")) | ||
if err != nil { | ||
t.Fatalf("failed to create temp symlink: %s", err) | ||
} | ||
|
||
_, err = NewUnpackInfo(dst, &tar.Header{ | ||
Name: "subdir/escapes", | ||
Typeflag: tar.TypeReg, | ||
}) | ||
|
||
if err == nil { | ||
t.Fatal("expected error, got nil") | ||
} | ||
|
||
expected := "through symlink" | ||
if !strings.Contains(err.Error(), expected) { | ||
t.Fatalf("expected error to contain %q, got %q", expected, err) | ||
} | ||
}) | ||
|
||
t.Run("disallow strange types", func(t *testing.T) { | ||
_, err := NewUnpackInfo("test", &tar.Header{ | ||
Name: "subdir/escapes", | ||
Typeflag: tar.TypeFifo, | ||
}) | ||
|
||
if err == nil { | ||
t.Fatal("expected error, got nil") | ||
} | ||
|
||
expected := "unsupported file type" | ||
if !strings.Contains(err.Error(), expected) { | ||
t.Fatalf("expected error to contain %q, got %q", expected, err) | ||
} | ||
}) | ||
} | ||
|
||
func TestUnpackInfo_RestoreInfo(t *testing.T) { | ||
root := t.TempDir() | ||
|
||
err := os.Mkdir(path.Join(root, "subdir"), 0700) | ||
if err != nil { | ||
t.Fatalf("failed to create temp subdir: %s", err) | ||
} | ||
|
||
err = os.WriteFile(path.Join(root, "bar.txt"), []byte("Hello, World!"), 0700) | ||
if err != nil { | ||
t.Fatalf("failed to create temp file: %s", err) | ||
} | ||
|
||
err = os.Symlink(path.Join(root, "bar.txt"), path.Join(root, "foo.txt")) | ||
if err != nil { | ||
t.Fatalf("failed to create temp symlink: %s", err) | ||
} | ||
|
||
exampleAccessTime := time.Date(2023, time.April, 1, 11, 22, 33, 0, time.UTC) | ||
exampleModTime := time.Date(2023, time.May, 29, 11, 22, 33, 0, time.UTC) | ||
|
||
dirinfo, err := NewUnpackInfo(root, &tar.Header{ | ||
Name: "subdir", | ||
Typeflag: tar.TypeDir, | ||
AccessTime: exampleAccessTime, | ||
ModTime: exampleModTime, | ||
Mode: 0666, | ||
}) | ||
if err != nil { | ||
t.Fatalf("failed to define dirinfo: %s", err) | ||
} | ||
|
||
finfo, err := NewUnpackInfo(root, &tar.Header{ | ||
Name: "bar.txt", | ||
Typeflag: tar.TypeReg, | ||
AccessTime: exampleAccessTime, | ||
ModTime: exampleModTime, | ||
Mode: 0666, | ||
}) | ||
if err != nil { | ||
t.Fatalf("failed to define finfo: %s", err) | ||
} | ||
|
||
linfo, err := NewUnpackInfo(root, &tar.Header{ | ||
Name: "foo.txt", | ||
Typeflag: tar.TypeSymlink, | ||
AccessTime: exampleAccessTime, | ||
ModTime: exampleModTime, | ||
Mode: 0666, | ||
}) | ||
if err != nil { | ||
t.Fatalf("failed to define linfo: %s", err) | ||
} | ||
|
||
infoCollection := []UnpackInfo{dirinfo, finfo, linfo} | ||
|
||
for _, info := range infoCollection { | ||
err = info.RestoreInfo() | ||
if err != nil { | ||
t.Errorf("failed to restore %q: %s", info.Path, err) | ||
} | ||
stat, err := os.Lstat(info.Path) | ||
if err != nil { | ||
t.Errorf("failed to lstat %q: %s", info.Path, err) | ||
} | ||
|
||
if !info.IsSymlink() { | ||
if stat.Mode() != info.OriginalMode { | ||
t.Errorf("%q mode %q did not match expected header mode %q", info.Path, stat.Mode(), info.OriginalMode) | ||
} | ||
} else if CanMaintainSymlinkTimestamps() { | ||
if !stat.ModTime().Truncate(time.Second).Equal(exampleModTime) { | ||
t.Errorf("%q modtime %q did not match example", info.Path, stat.ModTime()) | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.