From b39c4d1083cafb7c0dc12deeb4d7fe6a8d787304 Mon Sep 17 00:00:00 2001 From: Michael Henriksen Date: Fri, 10 Sep 2021 16:00:39 +0200 Subject: [PATCH] feat(archive): add functions for secure extraction of archives --- archive/doc.go | 4 + archive/errors.go | 16 ++ archive/reader.go | 24 +++ archive/tar/extract.go | 137 +++++++++++++++++ archive/tar/extract_test.go | 211 +++++++++++++++++++++++++++ archive/tar/testdata/simple.tar | Bin 0 -> 8704 bytes archive/tar/testdata/simple.tar.bz2 | Bin 0 -> 232 bytes archive/tar/testdata/simple.tar.gz | Bin 0 -> 212 bytes archive/tar/testdata/symlink.tar | Bin 0 -> 2048 bytes archive/tar/testdata/symlink.tar.bz2 | Bin 0 -> 202 bytes archive/tar/testdata/symlink.tar.gz | Bin 0 -> 160 bytes archive/tar/testdata/zipslip.tar | Bin 0 -> 10240 bytes archive/tar/testdata/zipslip.tar.bz2 | Bin 0 -> 153 bytes archive/tar/testdata/zipslip.tar.gz | Bin 0 -> 155 bytes archive/zip/extract.go | 97 ++++++++++++ archive/zip/extract_test.go | 108 ++++++++++++++ archive/zip/testdata/simple.zip | Bin 0 -> 498 bytes archive/zip/testdata/symlink.zip | Bin 0 -> 248 bytes archive/zip/testdata/zipslip.zip | Bin 0 -> 184 bytes commitlint.config.js | 1 + 20 files changed, 598 insertions(+) create mode 100644 archive/doc.go create mode 100644 archive/errors.go create mode 100644 archive/reader.go create mode 100644 archive/tar/extract.go create mode 100644 archive/tar/extract_test.go create mode 100644 archive/tar/testdata/simple.tar create mode 100644 archive/tar/testdata/simple.tar.bz2 create mode 100644 archive/tar/testdata/simple.tar.gz create mode 100644 archive/tar/testdata/symlink.tar create mode 100644 archive/tar/testdata/symlink.tar.bz2 create mode 100644 archive/tar/testdata/symlink.tar.gz create mode 100644 archive/tar/testdata/zipslip.tar create mode 100644 archive/tar/testdata/zipslip.tar.bz2 create mode 100644 archive/tar/testdata/zipslip.tar.gz create mode 100644 archive/zip/extract.go create mode 100644 archive/zip/extract_test.go create mode 100644 archive/zip/testdata/simple.zip create mode 100644 archive/zip/testdata/symlink.zip create mode 100644 archive/zip/testdata/zipslip.zip diff --git a/archive/doc.go b/archive/doc.go new file mode 100644 index 0000000..f3c4789 --- /dev/null +++ b/archive/doc.go @@ -0,0 +1,4 @@ +/* +Package archive provides secure functions for working with archive files. +*/ +package archive diff --git a/archive/errors.go b/archive/errors.go new file mode 100644 index 0000000..29dff74 --- /dev/null +++ b/archive/errors.go @@ -0,0 +1,16 @@ +package archive + +import "fmt" + +// InvalidFilenameError is the error returned by extract functions when a file name inside an +// archive contains directory traversals (../) or other sequences that attempt to manipulate the +// final destination path. +type InvalidFilenameError struct { + // Name contains the invalid file name. + Name string +} + +// Error returns a formatted error message. +func (e *InvalidFilenameError) Error() string { + return fmt.Sprintf("invalid filename: %s", e.Name) +} diff --git a/archive/reader.go b/archive/reader.go new file mode 100644 index 0000000..f90694d --- /dev/null +++ b/archive/reader.go @@ -0,0 +1,24 @@ +package archive + +import ( + "context" + "io" +) + +// CtxReader provides a context-aware io.Reader for extraction functions. +type CtxReader struct { + ctx context.Context + r io.Reader +} + +func (r *CtxReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} + +// NewReader returns a context-aware io.Reader. +func NewReader(ctx context.Context, r io.Reader) io.Reader { + return &CtxReader{ctx, r} +} diff --git a/archive/tar/extract.go b/archive/tar/extract.go new file mode 100644 index 0000000..ebd5a23 --- /dev/null +++ b/archive/tar/extract.go @@ -0,0 +1,137 @@ +package tar + +import ( + "archive/tar" + "compress/bzip2" + "compress/gzip" + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "gitlab.com/gitlab-org/labkit/archive" +) + +const ( + gzipMagicBytes = "\x1f\x8b" + bzip2MagicBytes = "\x42\x5a\x68" +) + +// Extract safely extracts an io.ReaderAt assumed to contain a tarball archive to the given +// destination path. If the destination path does not exist, it is automatically created. +// +// Gzip (tar.gz) and Bzip2 (tar.bz2) compression is automatically detected and decompressed. If no +// compression is detected, it is assumed to be a standard tarball with no compression. +// +// If the archive contains file names with Zip Slip-style directory traversal (../) and other +// sequences that attempt to have the file extracted outside of the intended destination directory, +// the function will stop extraction and return archive.InvalidFilenameError. +// +// The function only supports extraction of directories and regular files. +// Symbolic links, device files, named pipes, unix domain sockets, and other irregular file types +// are ignored as they are considered uncommon and can present a security risk. +// +// The function accepts a context in order to make it possible to cancel or time out the archive +// extraction, however, this is not a bullet proof protection against resource exhaustion attacks +// like Zip Bombs. If this is a concern, it is recommended to isolate archive extraction into its +// own process and limit resources with operating system controls like ulimit. +// +// No clean-up of extraction is performed if an error occurs, or if the context is caneled or times +// out. +// +// References: +// Zip Slip: https://snyk.io/research/zip-slip-vulnerability +// Zip Bomb: https://en.wikipedia.org/wiki/Zip_bomb +// Zip Symlink vulnerability: https://effortlesssecurity.in/2020/08/05/zip-symlink-vulnerability/ +// ulimit: https://ss64.com/bash/ulimit.html +func Extract(ctx context.Context, r io.ReadSeeker, dest string) error { + if err := os.MkdirAll(dest, 0750); err != nil { + return err + } + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + + stream, err := detectCompression(r) + if err != nil { + return err + } + + tr := tar.NewReader(stream) + + for { + if err := ctx.Err(); err != nil { + return err + } + + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if header.Name == "./" { + continue + } + + path := filepath.Join(dest, header.Name) // #nosec G305 + if !strings.HasPrefix(path, cleanDest) { + return &archive.InvalidFilenameError{Name: header.Name} + } + + if err := writeEntry(ctx, tr, header, path); err != nil { + return err + } + } + return nil +} + +func detectCompression(r io.ReadSeeker) (io.Reader, error) { + peek := make([]byte, 3) + if _, err := r.Read(peek); err != nil { + return nil, err + } + if _, err := r.Seek(0, 0); err != nil { + return nil, err + } + if string(peek) == bzip2MagicBytes { + return bzip2.NewReader(r), nil + } + if strings.HasPrefix(string(peek), gzipMagicBytes) { + return gzip.NewReader(r) + } + return r, nil +} + +func writeEntry(ctx context.Context, tr io.Reader, h *tar.Header, dest string) error { + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(ctx, tr, h, dest); err != nil { + return err + } + } + + return nil +} + +func writeFile(ctx context.Context, tr io.Reader, h *tar.Header, dest string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + df, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.FileMode(h.Mode)) + if err != nil { + return err + } + defer df.Close() + + if _, err := io.Copy(df, archive.NewReader(ctx, tr)); err != nil { + return err + } + return nil +} diff --git a/archive/tar/extract_test.go b/archive/tar/extract_test.go new file mode 100644 index 0000000..45bd57b --- /dev/null +++ b/archive/tar/extract_test.go @@ -0,0 +1,211 @@ +package tar_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/labkit/archive" + "gitlab.com/gitlab-org/labkit/archive/tar" +) + +func TestExtractTar(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.tar") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + c, err := os.ReadFile(filepath.Join(dest, "hello.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) + + c, err = os.ReadFile(filepath.Join(dest, "subdir", "hello2.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) +} + +func TestExtractTarGzip(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.tar.gz") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + c, err := os.ReadFile(filepath.Join(dest, "hello.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) + + c, err = os.ReadFile(filepath.Join(dest, "subdir", "hello2.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) +} + +func TestExtractTarBzip2(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.tar.bz2") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + c, err := os.ReadFile(filepath.Join(dest, "hello.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) + + c, err = os.ReadFile(filepath.Join(dest, "subdir", "hello2.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) +} + +func TestExtractTarZipSlip(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/zipslip.tar") + require.NoError(t, err) + defer f.Close() + + err = tar.Extract(context.Background(), f, filepath.Join(dest, "extract")) + + require.IsType(t, &archive.InvalidFilenameError{}, err) + require.Equal(t, "invalid filename: ../zipslip.txt", err.Error()) + _, err = os.Stat(filepath.Join(dest, "zipslip.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractTarGzipZipSlip(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/zipslip.tar.gz") + require.NoError(t, err) + defer f.Close() + + err = tar.Extract(context.Background(), f, filepath.Join(dest, "extract")) + + require.IsType(t, &archive.InvalidFilenameError{}, err) + require.Equal(t, "invalid filename: ../zipslip.txt", err.Error()) + _, err = os.Stat(filepath.Join(dest, "zipslip.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractTarBzip2ZipSlip(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/zipslip.tar.bz2") + require.NoError(t, err) + defer f.Close() + + err = tar.Extract(context.Background(), f, filepath.Join(dest, "extract")) + + require.IsType(t, &archive.InvalidFilenameError{}, err) + require.Equal(t, "invalid filename: ../zipslip.txt", err.Error()) + _, err = os.Stat(filepath.Join(dest, "zipslip.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractTarSymlink(t *testing.T) { + _, err := os.Create("/tmp/labkit-archive-symlink-test.txt") // #nosec G303 + require.NoError(t, err) + defer os.RemoveAll("/tmp/labkit-archive-symlink-test.txt") + + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/symlink.tar") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + _, err = os.Stat(filepath.Join(dest, "labkit-archive-symlink-test.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractTarGzipSymlink(t *testing.T) { + _, err := os.Create("/tmp/labkit-archive-symlink-test.txt") // #nosec G303 + require.NoError(t, err) + defer os.RemoveAll("/tmp/labkit-archive-symlink-test.txt") + + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/symlink.tar.gz") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + _, err = os.Stat(filepath.Join(dest, "labkit-archive-symlink-test.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractTarBzip2Symlink(t *testing.T) { + _, err := os.Create("/tmp/labkit-archive-symlink-test.txt") // #nosec G303 + require.NoError(t, err) + defer os.RemoveAll("/tmp/labkit-archive-symlink-test.txt") + + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/symlink.tar.bz2") + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tar.Extract(context.Background(), f, dest)) + + _, err = os.Stat(filepath.Join(dest, "labkit-archive-symlink-test.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractWithCanceledContext(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.tar") + require.NoError(t, err) + defer f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.ErrorIs(t, tar.Extract(ctx, f, dest), context.Canceled) +} + +func TestExtractWithTimedOutContext(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.tar") + require.NoError(t, err) + defer f.Close() + + ctx, cancel := context.WithTimeout(context.Background(), -1*time.Second) + defer cancel() + + require.ErrorIs(t, tar.Extract(ctx, f, dest), context.DeadlineExceeded) +} diff --git a/archive/tar/testdata/simple.tar b/archive/tar/testdata/simple.tar new file mode 100644 index 0000000000000000000000000000000000000000..131977164efec9b392d92d4104f41fc928b4e9ad GIT binary patch literal 8704 zcmdPXXP`MSFfcGTHC12$(Pj|Zz`y{^H-G{KLlZ+oLvup|Qxg*f14Bb|Q$q#?Q<}Ss zfq|j4xFoTN!N4FlGdUwMH3z4aM8M#iJ%KgDrNi~)N38L2rr`FbT4CB!HvOAY?=-@w4Y&=g+& z8yFgZ;@{NVbhP{@E3iq_CfDui~0h01RxcoOY zG&LU0|MW;*qxqj6p*(8GXb6mkz-R~zz7T-d|1@;kX#bCfflHy$qy4{;J^nk|{-ZDh j4RP)8@_)4dHzMbMM)Us=&%mS39}R)g5Eu;sGD83WD(~c+ literal 0 HcmV?d00001 diff --git a/archive/tar/testdata/simple.tar.bz2 b/archive/tar/testdata/simple.tar.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..22603beca6e84c40493e1c5d85fd4eb24de33e63 GIT binary patch literal 232 zcmZ>Y%CIzaj8qGb)cVl-i-94ze!`^*ObrQ){~H*bm>TQS<{e;RaN#gu_`%G}(>BwD zS>Up-l$60?XT@c{3`a~ZyS4Rjtz5Y%)lB7u5Hp`T00{= zqRd3d#~?soOdvO4*4mB2hm@wLyVXhF4E@j1KI?XKoe0zCCymGZ^-89m+UO|d;m4WR ztG?3HGNd4|)+p?{@S&yN0!5W37s8fWIJ-_*uxyf2W^SyupqjeyZsoQ6BQ7=j*6GO2Rtsm&d~?V9PwYvNhfWTw51GFJ0I-)_RR910 literal 0 HcmV?d00001 diff --git a/archive/tar/testdata/simple.tar.gz b/archive/tar/testdata/simple.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..9e6345c724aeeb9c8b57900446cdd78402acf291 GIT binary patch literal 212 zcmb2|=3rPgQ9Y4?`Rx>6u4V&)*3IuUFL57{d^|(@3tQ}MEsZ4{|1QYw4}8hr%H?!i zZ%)23C+8DY{d}J#Hy9b*mmHtvXZm5qWfozV5{@Sh&JuzezJD%Fd39SayJ?Ddsov&8 z^>@p4yJv=eE!6y5JlAK&insoiR}1QzRsS{jHvc!S-GQ literal 0 HcmV?d00001 diff --git a/archive/tar/testdata/symlink.tar b/archive/tar/testdata/symlink.tar new file mode 100644 index 0000000000000000000000000000000000000000..2e6b6c99b59e881dd2d925e36be473421f0c2ec2 GIT binary patch literal 2048 zcmdPXXP`MSFfcGTHC12$(Pj|Zz{mv5H-G{KLlZ+oGc!|TGjjt414Bb|b3+CNQ<}Ss zfq|j4xFoTN!N4FlGdUwMH3z4a#U+VpX*gvWK%O%&Fo1bV0guW-C8ejIlbDp9S)!X* zl$?=Sma1D^nVXZDm#tfpT3n)6Qc*$$*Mj1m*!VX!Hdin(GB+?XWKb~DFUc*SwfjbR T{8J%%M~xT_fzc2cq9FhP(9}Yx literal 0 HcmV?d00001 diff --git a/archive/tar/testdata/symlink.tar.bz2 b/archive/tar/testdata/symlink.tar.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..08361592eee12067756c33c024010540c08747dd GIT binary patch literal 202 zcmZ>Y%CIzaj8qGbTz+bUBLjnR^@IxzObrgq|63XutMlU%SR5EQ3>bDUY>+g$kYeEM zs0ruv3XH!BU>zR~yH=0C9~MIJgi J&DT1}0svHOQd$52 literal 0 HcmV?d00001 diff --git a/archive/tar/testdata/symlink.tar.gz b/archive/tar/testdata/symlink.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..49260ded806a3c87ebb1471dd941b27c6f61e641 GIT binary patch literal 160 zcmb2|=3uakuuf!Peml{T>#%}=t8JHffV=Ql8#4)|i$RaJm~3BfvT(N;S83p*+k4I$ zd~QF_x{{9}V(rVzX~rw+KJ!i8JLTAma}f&^mTsLC5Nj``c)cf2GcZU(?fK)|JB`fx zY%CIzaj8qGb%yo-B!@!VSKjEbSQ%eHl{{{wz2@Kh3_X1cL7&sIdiWJ=%E-qZm z!Pb1m)^Vbm+QP-oS(&e@+Wb~~O zFsR(iI!$H9Q_ri_Q?)X-WokIC-o*37?eq z@W^H9r~>tMB3$op+pzsR8?n23S$2p#7OC(tl4kg9ZZw0L|V& Ac>n+a literal 0 HcmV?d00001 diff --git a/archive/zip/extract.go b/archive/zip/extract.go new file mode 100644 index 0000000..446c8b2 --- /dev/null +++ b/archive/zip/extract.go @@ -0,0 +1,97 @@ +package zip + +import ( + "archive/zip" + "context" + "io" + "os" + "path/filepath" + "strings" + + "gitlab.com/gitlab-org/labkit/archive" +) + +// Extract safely extracts an io.ReaderAt assumed to contain a zip archive to the given destination +// path. If the destination path does not exist, it is automatically created. +// +// If the archive contains file names with Zip Slip-style directory traversal (../) and other +// sequences that attempt to have the file extracted outside of the intended destination directory, +// the function will stop extraction and return archive.InvalidFilenameError. +// +// The function only supports extraction of directories and regular files. +// Symbolic links, device files, named pipes, unix domain sockets, and other irregular file types +// are ignored as they are considered uncommon and can present a security risk. +// +// The function accepts a context in order to make it possible to cancel or time out the archive +// extraction, however, this is not a bullet proof protection against resource exhaustion attacks +// like Zip Bombs. If this is a concern, it is recommended to isolate archive extraction into its +// own process and limit resources with operating system controls like ulimit. +// +// No clean-up of extraction is performed if an error occurs, or if the context is caneled or times +// out. +// +// References: +// Zip Slip: https://snyk.io/research/zip-slip-vulnerability +// Zip Bomb: https://en.wikipedia.org/wiki/Zip_bomb +// Zip Symlink vulnerability: https://effortlesssecurity.in/2020/08/05/zip-symlink-vulnerability/ +// ulimit: https://ss64.com/bash/ulimit.html +func Extract(ctx context.Context, r io.ReaderAt, size int64, dest string) error { + zr, err := zip.NewReader(r, size) + if err != nil { + return err + } + if err := os.MkdirAll(dest, 0750); err != nil { + return err + } + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + for _, zf := range zr.File { + if err := ctx.Err(); err != nil { + return err + } + + path := filepath.Join(dest, zf.Name) // #nosec G305 + if !strings.HasPrefix(path, cleanDest) { + return &archive.InvalidFilenameError{Name: zf.Name} + } + + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0750); err != nil { + return err + } + continue + } + + if !zf.Mode().IsRegular() { + continue + } + + if err := writeFile(ctx, zf, path); err != nil { + return err + } + } + return nil +} + +func writeFile(ctx context.Context, zf *zip.File, dest string) error { + f, err := zf.Open() + if err != nil { + return err + } + defer f.Close() + + if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil { + return err + } + + wf, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zf.Mode()) + if err != nil { + return err + } + defer wf.Close() + + if _, err := io.Copy(wf, archive.NewReader(ctx, f)); err != nil { + return err + } + + return nil +} diff --git a/archive/zip/extract_test.go b/archive/zip/extract_test.go new file mode 100644 index 0000000..f0303a3 --- /dev/null +++ b/archive/zip/extract_test.go @@ -0,0 +1,108 @@ +package zip_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/labkit/archive" + "gitlab.com/gitlab-org/labkit/archive/zip" +) + +func TestExtract(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.zip") + require.NoError(t, err) + defer f.Close() + fi, err := f.Stat() + require.NoError(t, err) + + require.NoError(t, zip.Extract(context.Background(), f, fi.Size(), dest)) + + c, err := os.ReadFile(filepath.Join(dest, "hello.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) + + c, err = os.ReadFile(filepath.Join(dest, "subdir", "hello2.txt")) + require.NoError(t, err) + require.Equal(t, "Hello World!\n", string(c)) +} + +func TestExtractWithZipSlip(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/zipslip.zip") + require.NoError(t, err) + defer f.Close() + fi, err := f.Stat() + require.NoError(t, err) + + err = zip.Extract(context.Background(), f, fi.Size(), filepath.Join(dest, "extract")) + require.IsType(t, &archive.InvalidFilenameError{}, err) + require.Equal(t, "invalid filename: ../zipslip.txt", err.Error()) + _, err = os.Stat(filepath.Join(dest, "zipslip.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractWithSymlink(t *testing.T) { + _, err := os.Create("/tmp/labkit-archive-symlink-test.txt") // #nosec G303 + require.NoError(t, err) + defer os.RemoveAll("/tmp/labkit-archive-symlink-test.txt") + + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/symlink.zip") + require.NoError(t, err) + defer f.Close() + fi, err := f.Stat() + require.NoError(t, err) + + require.NoError(t, zip.Extract(context.Background(), f, fi.Size(), dest)) + + _, err = os.Stat(filepath.Join(dest, "labkit-archive-symlink-test.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestExtractWithCanceledContext(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.zip") + require.NoError(t, err) + defer f.Close() + fi, err := f.Stat() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.ErrorIs(t, zip.Extract(ctx, f, fi.Size(), dest), context.Canceled) +} + +func TestExtractWithTimedOutContext(t *testing.T) { + dest, err := os.MkdirTemp("", "labkit-test") + require.NoError(t, err) + defer os.RemoveAll(dest) + + f, err := os.Open("testdata/simple.zip") + require.NoError(t, err) + defer f.Close() + fi, err := f.Stat() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), -1*time.Second) + defer cancel() + + require.ErrorIs(t, zip.Extract(ctx, f, fi.Size(), dest), context.DeadlineExceeded) +} diff --git a/archive/zip/testdata/simple.zip b/archive/zip/testdata/simple.zip new file mode 100644 index 0000000000000000000000000000000000000000..69d774f052d586803bde8b88478324c8bedeaf24 GIT binary patch literal 498 zcmWIWW@h1H0D+=7so=YJMQV8&7#Kj9lR<_dBQ+-{U$3O1Bs7GRfmuC8JrRUUE4UdL zS-vtdFtC6$dVmxugy$FKq$qMB%xjC23I<8TFgt?`Lvd+RN@kHh*fgIM^~AR+>WOHk zp&EefAVGuyV0Rh89R@NAgwc#ba~LC&95XJjN`PI-$iTp`q!GjfTL3Yi726BBEm4R6QN;%%UD)6kY|_~m>E7Z KFfhCXaTox2&RYWj literal 0 HcmV?d00001 diff --git a/archive/zip/testdata/symlink.zip b/archive/zip/testdata/symlink.zip new file mode 100644 index 0000000000000000000000000000000000000000..ba954cacdd8129f3a0d461d7d9406bdc94ea14ef GIT binary patch literal 248 zcmWIWW@h1H0D<6Qtzb)S+1Dxz3=AMF&mhB)lbDp9S)!X*l$?=Sma1D^nVXZDm#tfp zT3n)6Qc)5b!pXq=&(As$gi9;985miRxr&B_MS&d9*XkjlWoPz&NP0035XJa7O2 literal 0 HcmV?d00001 diff --git a/archive/zip/testdata/zipslip.zip b/archive/zip/testdata/zipslip.zip new file mode 100644 index 0000000000000000000000000000000000000000..d449c33b2088e83c4b63a6d297002f3eda9c824d GIT binary patch literal 184 zcmWIWW@h1H0D+cNso;0TIjn393=AO5#~{O?r>9?)Sx}slS)f-^Q4$)$$-rD*pq>cA zr4`%^j4WRn85meVy8ZJDiWRv6ycwC~m~ojW0aD1o$iTp`q!GkKH=Pw?I-1D=-e4y& RFfuSQgfK8L_<^)C0079vAX5MU literal 0 HcmV?d00001 diff --git a/commitlint.config.js b/commitlint.config.js index f703778..63a6b3c 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -36,6 +36,7 @@ module.exports = { 2, 'always', [ + 'archive', 'correlation', 'errortracking', 'log', -- GitLab