Skip to content

Commit

Permalink
Restore original timestamps when unpacking
Browse files Browse the repository at this point in the history
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
brandonc committed Oct 12, 2023
1 parent aeb5117 commit aa2ab2b
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 91 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ require (
require (
github.com/go-test/deep v1.0.3 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.6.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
17 changes: 17 additions & 0 deletions internal/unpackinfo/lchtimes_linux_darwin.go
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()}},
)
}
14 changes: 14 additions & 0 deletions internal/unpackinfo/lchtimes_others.go
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")
}
165 changes: 165 additions & 0 deletions internal/unpackinfo/unpackinfo.go
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 {

Check failure on line 150 in internal/unpackinfo/unpackinfo.go

View workflow job for this annotation

GitHub Actions / unit-test

i.Lchtimes undefined (type UnpackInfo has no field or method Lchtimes)
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
}
147 changes: 147 additions & 0 deletions internal/unpackinfo/unpackinfo_test.go
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())
}
}
}
}
Loading

0 comments on commit aa2ab2b

Please sign in to comment.