Skip to content

Commit 96dc088

Browse files
authored
Cap total decompressed size in tar and gem readers to prevent memory exhaustion (#7)
Wraps decompressed reads with `io.LimitReader` tracking cumulative bytes across all entries, rejecting archives that exceed 512 MiB decompressed. Applies to both `openTar` (per-entry and cumulative) and `openGem` (`data.tar.gz` read).
1 parent 52ba64f commit 96dc088

3 files changed

Lines changed: 147 additions & 4 deletions

File tree

archives_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"archive/zip"
66
"bytes"
77
"compress/gzip"
8+
"errors"
9+
"fmt"
810
"io"
911
"os"
1012
"strings"
@@ -569,3 +571,132 @@ func TestGetStripPrefixNpm(t *testing.T) {
569571
}
570572
}
571573
}
574+
575+
func TestOpenTarRejectsDecompressBomb(t *testing.T) {
576+
oldMax := maxDecompressedSize
577+
maxDecompressedSize = 1024
578+
defer func() { maxDecompressedSize = oldMax }()
579+
580+
buf := new(bytes.Buffer)
581+
gw := gzip.NewWriter(buf)
582+
tw := tar.NewWriter(gw)
583+
584+
content := strings.Repeat("x", 2048)
585+
_ = tw.WriteHeader(&tar.Header{
586+
Name: "big.txt",
587+
Size: int64(len(content)),
588+
Mode: 0644,
589+
})
590+
_, _ = tw.Write([]byte(content))
591+
_ = tw.Close()
592+
_ = gw.Close()
593+
594+
_, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
595+
if err == nil {
596+
t.Fatal("expected error for oversized decompressed content")
597+
}
598+
if !errors.Is(err, ErrDecompressLimit) {
599+
t.Fatalf("expected ErrDecompressLimit, got: %v", err)
600+
}
601+
}
602+
603+
func TestOpenTarAcceptsWithinLimit(t *testing.T) {
604+
oldMax := maxDecompressedSize
605+
maxDecompressedSize = 4096
606+
defer func() { maxDecompressedSize = oldMax }()
607+
608+
buf := new(bytes.Buffer)
609+
gw := gzip.NewWriter(buf)
610+
tw := tar.NewWriter(gw)
611+
612+
content := strings.Repeat("x", 1024)
613+
_ = tw.WriteHeader(&tar.Header{
614+
Name: "ok.txt",
615+
Size: int64(len(content)),
616+
Mode: 0644,
617+
})
618+
_, _ = tw.Write([]byte(content))
619+
_ = tw.Close()
620+
_ = gw.Close()
621+
622+
reader, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
623+
if err != nil {
624+
t.Fatalf("unexpected error: %v", err)
625+
}
626+
627+
files, _ := reader.List()
628+
if len(files) != 1 {
629+
t.Fatalf("expected 1 file, got %d", len(files))
630+
}
631+
if files[0].Path != "ok.txt" {
632+
t.Errorf("expected ok.txt, got %s", files[0].Path)
633+
}
634+
}
635+
636+
func TestOpenTarRejectsCumulativeOverflow(t *testing.T) {
637+
oldMax := maxDecompressedSize
638+
maxDecompressedSize = 1024
639+
defer func() { maxDecompressedSize = oldMax }()
640+
641+
buf := new(bytes.Buffer)
642+
gw := gzip.NewWriter(buf)
643+
tw := tar.NewWriter(gw)
644+
645+
for i := 0; i < 3; i++ {
646+
content := strings.Repeat("y", 512)
647+
_ = tw.WriteHeader(&tar.Header{
648+
Name: fmt.Sprintf("file%d.txt", i),
649+
Size: int64(len(content)),
650+
Mode: 0644,
651+
})
652+
_, _ = tw.Write([]byte(content))
653+
}
654+
_ = tw.Close()
655+
_ = gw.Close()
656+
657+
_, err := openTar(bytes.NewReader(buf.Bytes()), "gzip")
658+
if err == nil {
659+
t.Fatal("expected error when cumulative size exceeds limit")
660+
}
661+
if !errors.Is(err, ErrDecompressLimit) {
662+
t.Fatalf("expected ErrDecompressLimit, got: %v", err)
663+
}
664+
}
665+
666+
func TestOpenGemRejectsOversizedData(t *testing.T) {
667+
oldMax := maxDecompressedSize
668+
maxDecompressedSize = 512
669+
defer func() { maxDecompressedSize = oldMax }()
670+
671+
// Build a data.tar.gz that decompresses larger than the limit
672+
var innerBuf bytes.Buffer
673+
innerGw := gzip.NewWriter(&innerBuf)
674+
innerTw := tar.NewWriter(innerGw)
675+
676+
content := strings.Repeat("z", 1024)
677+
_ = innerTw.WriteHeader(&tar.Header{
678+
Name: "lib/main.rb",
679+
Size: int64(len(content)),
680+
Mode: 0644,
681+
})
682+
_, _ = innerTw.Write([]byte(content))
683+
_ = innerTw.Close()
684+
_ = innerGw.Close()
685+
686+
// Wrap in outer gem tar
687+
var gemBuf bytes.Buffer
688+
outerTw := tar.NewWriter(&gemBuf)
689+
dataTarGz := innerBuf.Bytes()
690+
_ = outerTw.WriteHeader(&tar.Header{
691+
Name: "data.tar.gz",
692+
Size: int64(len(dataTarGz)),
693+
Mode: 0644,
694+
})
695+
_, _ = outerTw.Write(dataTarGz)
696+
_ = outerTw.Close()
697+
698+
_, err := openGem(bytes.NewReader(gemBuf.Bytes()))
699+
if err == nil {
700+
t.Fatal("expected error for oversized gem data")
701+
}
702+
}

gem.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ func openGem(content io.Reader) (*gemReader, error) {
2929

3030
// Look for data.tar.gz
3131
if header.Name == "data.tar.gz" {
32-
// Read the data.tar.gz content
33-
dataContent, err := io.ReadAll(tr)
32+
dataContent, err := io.ReadAll(io.LimitReader(tr, maxDecompressedSize+1))
3433
if err != nil {
3534
return nil, fmt.Errorf("reading data.tar.gz: %w", err)
3635
}
36+
if int64(len(dataContent)) > maxDecompressedSize {
37+
return nil, fmt.Errorf("%w: data.tar.gz exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize)
38+
}
3739

3840
// Open the inner tar.gz
3941
dataReader, err := openTar(io.NopCloser(newBytesReader(dataContent)), "gzip")

tar.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import (
55
"bytes"
66
"compress/bzip2"
77
"compress/gzip"
8+
"errors"
89
"fmt"
910
"io"
1011
"strings"
1112

1213
"github.com/ulikunitz/xz"
1314
)
1415

16+
var maxDecompressedSize int64 = 512 << 20 // 512 MiB
17+
18+
var ErrDecompressLimit = errors.New("decompressed content exceeds size limit")
19+
1520
type tarReader struct {
1621
files []tarFileEntry
1722
}
@@ -43,9 +48,9 @@ func openTar(content io.Reader, compression string) (*tarReader, error) {
4348
r = xzReader
4449
}
4550

46-
// Read all files into memory
4751
tr := tar.NewReader(r)
4852
var files []tarFileEntry
53+
var totalSize int64
4954

5055
for {
5156
header, err := tr.Next()
@@ -67,10 +72,15 @@ func openTar(content io.Reader, compression string) (*tarReader, error) {
6772

6873
var data []byte
6974
if !info.IsDir {
70-
data, err = io.ReadAll(tr)
75+
remaining := maxDecompressedSize - totalSize
76+
data, err = io.ReadAll(io.LimitReader(tr, remaining+1))
7177
if err != nil {
7278
return nil, fmt.Errorf("reading file %s: %w", header.Name, err)
7379
}
80+
if int64(len(data)) > remaining {
81+
return nil, fmt.Errorf("%w: exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize)
82+
}
83+
totalSize += int64(len(data))
7484
}
7585

7686
files = append(files, tarFileEntry{

0 commit comments

Comments
 (0)