From 821abc7a7ff4234f8f30713d8633b6218732e1fa Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 24 Aug 2026 10:26:59 +0100 Subject: [PATCH 1/3] Limit archive entry counts --- archives_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ conda.go | 6 ++++++ conda_test.go | 11 ++++++++++ gem.go | 5 +++++ tar.go | 16 +++++++++++++- zip.go | 3 +++ 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/archives_test.go b/archives_test.go index 524e674..5bbc7ca 100644 --- a/archives_test.go +++ b/archives_test.go @@ -858,6 +858,60 @@ func TestOpenTarRejectsCumulativeOverflow(t *testing.T) { } } +func TestOpenTarRejectsTooManyEntries(t *testing.T) { + oldMax := maxArchiveEntries + maxArchiveEntries = 2 + defer func() { maxArchiveEntries = oldMax }() + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for i := 0; i < 3; i++ { + _ = tw.WriteHeader(&tar.Header{Name: fmt.Sprintf("empty-%d", i), Mode: 0644}) + } + _ = tw.Close() + + _, err := openTar(buf.Bytes(), "") + if !errors.Is(err, ErrEntryLimit) { + t.Fatalf("expected ErrEntryLimit, got: %v", err) + } +} + +func TestOpenZipRejectsTooManyEntries(t *testing.T) { + oldMax := maxArchiveEntries + maxArchiveEntries = 2 + defer func() { maxArchiveEntries = oldMax }() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for i := 0; i < 3; i++ { + _, _ = zw.Create(fmt.Sprintf("empty-%d", i)) + } + _ = zw.Close() + + _, err := openZip(buf.Bytes()) + if !errors.Is(err, ErrEntryLimit) { + t.Fatalf("expected ErrEntryLimit, got: %v", err) + } +} + +func TestOpenGemRejectsTooManyEntries(t *testing.T) { + oldMax := maxArchiveEntries + maxArchiveEntries = 2 + defer func() { maxArchiveEntries = oldMax }() + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for i := 0; i < 3; i++ { + _ = tw.WriteHeader(&tar.Header{Name: fmt.Sprintf("empty-%d", i), Mode: 0644}) + } + _ = tw.Close() + + _, err := openGem(buf.Bytes()) + if !errors.Is(err, ErrEntryLimit) { + t.Fatalf("expected ErrEntryLimit, got: %v", err) + } +} + func TestOpenGemRejectsOversizedData(t *testing.T) { oldMax := maxDecompressedSize maxDecompressedSize = 512 diff --git a/conda.go b/conda.go index dba636f..30091dc 100644 --- a/conda.go +++ b/conda.go @@ -22,6 +22,9 @@ func openConda(raw []byte) (*tarReader, error) { if err != nil { return nil, fmt.Errorf("opening conda zip: %w", err) } + if err := checkArchiveEntryCount(len(zr.File)); err != nil { + return nil, err + } var files []tarFileEntry var total int64 @@ -40,6 +43,9 @@ func openConda(raw []byte) (*tarReader, error) { if total > maxDecompressedSize { return nil, fmt.Errorf("%w: exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize) } + if err := checkArchiveEntryCount(len(files) + len(entries)); err != nil { + return nil, err + } files = append(files, entries...) } if files == nil { diff --git a/conda_test.go b/conda_test.go index 1b09dcf..0d78ae7 100644 --- a/conda_test.go +++ b/conda_test.go @@ -186,6 +186,17 @@ func TestOpenCondaRejectsCumulativeOverflow(t *testing.T) { } } +func TestOpenCondaRejectsTooManyEntriesAcrossMembers(t *testing.T) { + oldMax := maxArchiveEntries + maxArchiveEntries = 3 + defer func() { maxArchiveEntries = oldMax }() + + _, err := openConda(createTestConda(t)) + if !errors.Is(err, ErrEntryLimit) { + t.Fatalf("expected ErrEntryLimit, got: %v", err) + } +} + func TestOpenDoesNotInferConda(t *testing.T) { reader, err := OpenBytes("artifact", createTestConda(t)) if err != nil { diff --git a/gem.go b/gem.go index b7706c5..c5edcc8 100644 --- a/gem.go +++ b/gem.go @@ -17,6 +17,7 @@ type gemReader struct { func openGem(raw []byte) (*gemReader, error) { tr := tar.NewReader(bytes.NewReader(raw)) + entryCount := 0 // Find data.tar.gz in the gem for { @@ -27,6 +28,10 @@ func openGem(raw []byte) (*gemReader, error) { if err != nil { return nil, fmt.Errorf("reading gem tar: %w", err) } + entryCount++ + if err := checkArchiveEntryCount(entryCount); err != nil { + return nil, err + } // Look for data.tar.gz if header.Name == "data.tar.gz" { diff --git a/tar.go b/tar.go index e33a710..b607c80 100644 --- a/tar.go +++ b/tar.go @@ -15,9 +15,13 @@ import ( "github.com/ulikunitz/xz" ) -var maxDecompressedSize int64 = 512 << 20 // 512 MiB +var ( + maxDecompressedSize int64 = 512 << 20 // 512 MiB + maxArchiveEntries = 100_000 +) var ErrDecompressLimit = errors.New("decompressed content exceeds size limit") +var ErrEntryLimit = errors.New("archive entry count exceeds limit") type tarReader struct { raw []byte @@ -71,6 +75,9 @@ func openTar(raw []byte, compression string) (*tarReader, error) { if err != nil { return nil, fmt.Errorf("reading tar: %w", err) } + if err := checkArchiveEntryCount(len(files) + 1); err != nil { + return nil, err + } // FileInfo().Mode() combines header.Mode permission bits with type // bits derived from Typeflag. It reports hard links as regular @@ -119,6 +126,13 @@ func openTar(raw []byte, compression string) (*tarReader, error) { return &tarReader{raw: raw, files: files, index: index}, nil } +func checkArchiveEntryCount(count int) error { + if count > maxArchiveEntries { + return fmt.Errorf("%w: %d entries exceeds %d", ErrEntryLimit, count, maxArchiveEntries) + } + return nil +} + func (t *tarReader) List() ([]FileInfo, error) { files := make([]FileInfo, len(t.files)) for i, f := range t.files { diff --git a/zip.go b/zip.go index 025dd88..5d847f3 100644 --- a/zip.go +++ b/zip.go @@ -32,6 +32,9 @@ func openZip(raw []byte) (*zipReader, error) { if err != nil { return nil, fmt.Errorf("opening zip: %w", err) } + if err := checkArchiveEntryCount(len(reader.File)); err != nil { + return nil, err + } index := make(map[string]*zip.File, len(reader.File)) for _, f := range reader.File { From 63107aed55b762a8c62152b3e7582bc831ad4dd8 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 27 Aug 2026 16:10:50 +0100 Subject: [PATCH 2/3] Enforce archive entry limits before allocation --- archives_test.go | 10 ++++- conda.go | 9 ++-- conda_test.go | 38 +++++++++++++++++ tar.go | 14 +++++-- zip.go | 106 +++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/archives_test.go b/archives_test.go index 5bbc7ca..8618263 100644 --- a/archives_test.go +++ b/archives_test.go @@ -6,6 +6,7 @@ import ( "bytes" "compress/gzip" "encoding/base64" + "encoding/binary" "errors" "fmt" "io" @@ -887,8 +888,15 @@ func TestOpenZipRejectsTooManyEntries(t *testing.T) { _, _ = zw.Create(fmt.Sprintf("empty-%d", i)) } _ = zw.Close() + raw := buf.Bytes() + directoryEnd := bytes.LastIndex(raw, []byte{'P', 'K', 0x05, 0x06}) + if directoryEnd < 0 { + t.Fatal("zip end-of-directory record not found") + } + binary.LittleEndian.PutUint16(raw[directoryEnd+8:], 1) + binary.LittleEndian.PutUint16(raw[directoryEnd+10:], 1) - _, err := openZip(buf.Bytes()) + _, err := OpenBytes("too-many.zip", raw) if !errors.Is(err, ErrEntryLimit) { t.Fatalf("expected ErrEntryLimit, got: %v", err) } diff --git a/conda.go b/conda.go index 30091dc..541fc7f 100644 --- a/conda.go +++ b/conda.go @@ -18,6 +18,9 @@ import ( // merged tarReader with raw pointing at the outer .conda bytes so Hash // matches the digest anaconda.org publishes in repodata.json. func openConda(raw []byte) (*tarReader, error) { + if err := checkZipEntryCount(raw); err != nil { + return nil, err + } zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { return nil, fmt.Errorf("opening conda zip: %w", err) @@ -35,7 +38,7 @@ func openConda(raw []byte) (*tarReader, error) { if !strings.HasPrefix(f.Name, "pkg-") && !strings.HasPrefix(f.Name, "info-") { continue } - entries, size, err := readCondaMember(f) + entries, size, err := readCondaMember(f, maxArchiveEntries-len(files)) if err != nil { return nil, err } @@ -62,7 +65,7 @@ func openConda(raw []byte) (*tarReader, error) { return &tarReader{raw: raw, files: files, index: index}, nil } -func readCondaMember(f *zip.File) ([]tarFileEntry, int64, error) { +func readCondaMember(f *zip.File, entryLimit int) ([]tarFileEntry, int64, error) { rc, err := f.Open() if err != nil { return nil, 0, fmt.Errorf("opening %s: %w", f.Name, err) @@ -77,7 +80,7 @@ func readCondaMember(f *zip.File) ([]tarFileEntry, int64, error) { return nil, 0, fmt.Errorf("%w: %s exceeds %d bytes", ErrDecompressLimit, f.Name, maxDecompressedSize) } - tr, err := openTar(data, "zstd") + tr, err := openTarWithEntryLimit(data, "zstd", entryLimit) if err != nil { return nil, 0, fmt.Errorf("opening %s: %w", f.Name, err) } diff --git a/conda_test.go b/conda_test.go index 0d78ae7..578910e 100644 --- a/conda_test.go +++ b/conda_test.go @@ -197,6 +197,44 @@ func TestOpenCondaRejectsTooManyEntriesAcrossMembers(t *testing.T) { } } +func TestOpenCondaStopsAtCombinedEntryLimit(t *testing.T) { + oldEntryMax := maxArchiveEntries + oldSizeMax := maxDecompressedSize + maxArchiveEntries = 2 + maxDecompressedSize = 512 + defer func() { + maxArchiveEntries = oldEntryMax + maxDecompressedSize = oldSizeMax + }() + + members := []struct { + name string + data []byte + }{ + {"pkg-a-1.tar.zst", writeTarZst(t, map[string]string{"first": "", "second": ""})}, + {"info-a-1.tar.zst", writeTarZst(t, map[string]string{"large": strings.Repeat("x", 1024)})}, + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, member := range members { + w, err := zw.CreateHeader(&zip.FileHeader{Name: member.name, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(member.data); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + _, err := OpenBytes("a.conda", buf.Bytes()) + if !errors.Is(err, ErrEntryLimit) { + t.Fatalf("expected ErrEntryLimit before reading the next member, got: %v", err) + } +} + func TestOpenDoesNotInferConda(t *testing.T) { reader, err := OpenBytes("artifact", createTestConda(t)) if err != nil { diff --git a/tar.go b/tar.go index b607c80..6b0a908 100644 --- a/tar.go +++ b/tar.go @@ -35,6 +35,10 @@ type tarFileEntry struct { } func openTar(raw []byte, compression string) (*tarReader, error) { + return openTarWithEntryLimit(raw, compression, maxArchiveEntries) +} + +func openTarWithEntryLimit(raw []byte, compression string, entryLimit int) (*tarReader, error) { content := bytes.NewReader(raw) r := io.Reader(content) @@ -75,7 +79,7 @@ func openTar(raw []byte, compression string) (*tarReader, error) { if err != nil { return nil, fmt.Errorf("reading tar: %w", err) } - if err := checkArchiveEntryCount(len(files) + 1); err != nil { + if err := checkArchiveEntryCountLimit(len(files)+1, entryLimit); err != nil { return nil, err } @@ -127,8 +131,12 @@ func openTar(raw []byte, compression string) (*tarReader, error) { } func checkArchiveEntryCount(count int) error { - if count > maxArchiveEntries { - return fmt.Errorf("%w: %d entries exceeds %d", ErrEntryLimit, count, maxArchiveEntries) + return checkArchiveEntryCountLimit(count, maxArchiveEntries) +} + +func checkArchiveEntryCountLimit(count, limit int) error { + if count > limit { + return fmt.Errorf("%w: count %d exceeds %d", ErrEntryLimit, count, limit) } return nil } diff --git a/zip.go b/zip.go index 5d847f3..838fff9 100644 --- a/zip.go +++ b/zip.go @@ -3,6 +3,7 @@ package archives import ( "archive/zip" "bytes" + "encoding/binary" "fmt" "io" "strings" @@ -15,10 +16,22 @@ import ( // then returns a synthesised 0666/0444 that callers should not treat as a // stored mode. const ( - zipUnixModeShift = 16 - zipCreatorShift = 8 - zipCreatorUnix = 3 - zipCreatorMacOSX = 19 + zipUnixModeShift = 16 + zipCreatorShift = 8 + zipCreatorUnix = 3 + zipCreatorMacOSX = 19 + zipSignatureLen = 4 + zipDirectoryHeaderLen = 46 + zipDirectoryEndLen = 22 + zipDirectory64EndLen = 56 + zipDirectory64LocatorLen = 20 + zipDirectoryHeaderSignature = 0x02014b50 + zipDirectoryEndSignature = 0x06054b50 + zipDirectory64EndSignature = 0x06064b50 + zipDirectory64LocSignature = 0x07064b50 + zipDirectoryEndSearchWindow = 65 * 1024 + zipDirectoryRecords64Marker = 0xffff + zipDirectorySizeOffsetMarker = 0xffffffff ) type zipReader struct { @@ -28,6 +41,9 @@ type zipReader struct { } func openZip(raw []byte) (*zipReader, error) { + if err := checkZipEntryCount(raw); err != nil { + return nil, err + } reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { return nil, fmt.Errorf("opening zip: %w", err) @@ -50,6 +66,88 @@ func openZip(raw []byte) (*zipReader, error) { }, nil } +func checkZipEntryCount(raw []byte) error { + start, end, ok := zipCentralDirectoryBounds(raw) + if !ok { + return nil + } + + count := 0 + for offset := start; offset+zipDirectoryHeaderLen <= end; { + if binary.LittleEndian.Uint32(raw[offset:]) != zipDirectoryHeaderSignature { + break + } + nameLen := int(binary.LittleEndian.Uint16(raw[offset+28:])) + extraLen := int(binary.LittleEndian.Uint16(raw[offset+30:])) + commentLen := int(binary.LittleEndian.Uint16(raw[offset+32:])) + recordLen := zipDirectoryHeaderLen + nameLen + extraLen + commentLen + if recordLen > end-offset { + return nil + } + + count++ + if err := checkArchiveEntryCount(count); err != nil { + return err + } + offset += recordLen + } + + return nil +} + +func zipCentralDirectoryBounds(raw []byte) (int, int, bool) { + searchStart := len(raw) - zipDirectoryEndSearchWindow + if searchStart < 0 { + searchStart = 0 + } + + for offset := len(raw) - zipDirectoryEndLen; offset >= searchStart; offset-- { + if binary.LittleEndian.Uint32(raw[offset:]) != zipDirectoryEndSignature { + continue + } + commentLen := int(binary.LittleEndian.Uint16(raw[offset+20:])) + if offset+zipDirectoryEndLen+commentLen > len(raw) { + continue + } + + directoryEnd := offset + directoryRecords := binary.LittleEndian.Uint16(raw[offset+10:]) + directorySize := uint64(binary.LittleEndian.Uint32(raw[offset+12:])) + directoryOffset := uint64(binary.LittleEndian.Uint32(raw[offset+16:])) + if directoryRecords == zipDirectoryRecords64Marker || + directorySize == zipDirectorySizeOffsetMarker || + directoryOffset == zipDirectorySizeOffsetMarker { + locatorOffset := offset - zipDirectory64LocatorLen + if locatorOffset < 0 || binary.LittleEndian.Uint32(raw[locatorOffset:]) != zipDirectory64LocSignature { + return 0, 0, false + } + zip64Offset := binary.LittleEndian.Uint64(raw[locatorOffset+8:]) + if len(raw) < zipDirectory64EndLen || zip64Offset > uint64(len(raw)-zipDirectory64EndLen) { + return 0, 0, false + } + directoryEnd = int(zip64Offset) + if binary.LittleEndian.Uint32(raw[directoryEnd:]) != zipDirectory64EndSignature { + return 0, 0, false + } + directorySize = binary.LittleEndian.Uint64(raw[directoryEnd+40:]) + directoryOffset = binary.LittleEndian.Uint64(raw[directoryEnd+48:]) + } + + if directorySize > uint64(directoryEnd) { + return 0, 0, false + } + start := directoryEnd - int(directorySize) + if directoryOffset < uint64(directoryEnd)-directorySize && + directoryOffset <= uint64(len(raw)-zipSignatureLen) && + binary.LittleEndian.Uint32(raw[int(directoryOffset):]) == zipDirectoryHeaderSignature { + start = int(directoryOffset) + } + return start, directoryEnd, true + } + + return 0, 0, false +} + func (z *zipReader) List() ([]FileInfo, error) { files := make([]FileInfo, 0, len(z.reader.File)) From 198ca9035279128ddddd8c00f291f16d76041ed4 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 27 Aug 2026 16:31:17 +0100 Subject: [PATCH 3/3] Report combined conda entry counts --- conda.go | 9 +++------ conda_test.go | 3 +++ tar.go | 14 +++++--------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/conda.go b/conda.go index 541fc7f..c4786f4 100644 --- a/conda.go +++ b/conda.go @@ -38,7 +38,7 @@ func openConda(raw []byte) (*tarReader, error) { if !strings.HasPrefix(f.Name, "pkg-") && !strings.HasPrefix(f.Name, "info-") { continue } - entries, size, err := readCondaMember(f, maxArchiveEntries-len(files)) + entries, size, err := readCondaMember(f, len(files)) if err != nil { return nil, err } @@ -46,9 +46,6 @@ func openConda(raw []byte) (*tarReader, error) { if total > maxDecompressedSize { return nil, fmt.Errorf("%w: exceeds %d bytes", ErrDecompressLimit, maxDecompressedSize) } - if err := checkArchiveEntryCount(len(files) + len(entries)); err != nil { - return nil, err - } files = append(files, entries...) } if files == nil { @@ -65,7 +62,7 @@ func openConda(raw []byte) (*tarReader, error) { return &tarReader{raw: raw, files: files, index: index}, nil } -func readCondaMember(f *zip.File, entryLimit int) ([]tarFileEntry, int64, error) { +func readCondaMember(f *zip.File, initialEntryCount int) ([]tarFileEntry, int64, error) { rc, err := f.Open() if err != nil { return nil, 0, fmt.Errorf("opening %s: %w", f.Name, err) @@ -80,7 +77,7 @@ func readCondaMember(f *zip.File, entryLimit int) ([]tarFileEntry, int64, error) return nil, 0, fmt.Errorf("%w: %s exceeds %d bytes", ErrDecompressLimit, f.Name, maxDecompressedSize) } - tr, err := openTarWithEntryLimit(data, "zstd", entryLimit) + tr, err := openTarWithInitialEntryCount(data, "zstd", initialEntryCount) if err != nil { return nil, 0, fmt.Errorf("opening %s: %w", f.Name, err) } diff --git a/conda_test.go b/conda_test.go index 578910e..f284514 100644 --- a/conda_test.go +++ b/conda_test.go @@ -233,6 +233,9 @@ func TestOpenCondaStopsAtCombinedEntryLimit(t *testing.T) { if !errors.Is(err, ErrEntryLimit) { t.Fatalf("expected ErrEntryLimit before reading the next member, got: %v", err) } + if !strings.Contains(err.Error(), "count 3 exceeds 2") { + t.Fatalf("expected combined entry count in error, got: %v", err) + } } func TestOpenDoesNotInferConda(t *testing.T) { diff --git a/tar.go b/tar.go index 6b0a908..742aba2 100644 --- a/tar.go +++ b/tar.go @@ -35,10 +35,10 @@ type tarFileEntry struct { } func openTar(raw []byte, compression string) (*tarReader, error) { - return openTarWithEntryLimit(raw, compression, maxArchiveEntries) + return openTarWithInitialEntryCount(raw, compression, 0) } -func openTarWithEntryLimit(raw []byte, compression string, entryLimit int) (*tarReader, error) { +func openTarWithInitialEntryCount(raw []byte, compression string, initialEntryCount int) (*tarReader, error) { content := bytes.NewReader(raw) r := io.Reader(content) @@ -79,7 +79,7 @@ func openTarWithEntryLimit(raw []byte, compression string, entryLimit int) (*tar if err != nil { return nil, fmt.Errorf("reading tar: %w", err) } - if err := checkArchiveEntryCountLimit(len(files)+1, entryLimit); err != nil { + if err := checkArchiveEntryCount(initialEntryCount + len(files) + 1); err != nil { return nil, err } @@ -131,12 +131,8 @@ func openTarWithEntryLimit(raw []byte, compression string, entryLimit int) (*tar } func checkArchiveEntryCount(count int) error { - return checkArchiveEntryCountLimit(count, maxArchiveEntries) -} - -func checkArchiveEntryCountLimit(count, limit int) error { - if count > limit { - return fmt.Errorf("%w: count %d exceeds %d", ErrEntryLimit, count, limit) + if count > maxArchiveEntries { + return fmt.Errorf("%w: count %d exceeds %d", ErrEntryLimit, count, maxArchiveEntries) } return nil }