//go:build linux || freebsd // +build linux freebsd package archive import ( "archive/zip" "fmt" "io" "os" "path/filepath" "strings" ) type zipExtractor struct{ *BaseExtractor } // MaxZipInputSize is the maximum size in bytes of a single file extracted // from a zip archive. Files whose declared uncompressed size exceeds this // limit are rejected outright; the limit is also enforced at copy time as // a safety net in case the central directory lies (a known zip-bomb // technique). Defaults to 1 GiB. var MaxZipInputSize int64 = 1 << 30 // MaxZipBufferSize is the maximum size in bytes of the buffered // (compressed) zip archive before it is opened. Archives larger than this // are rejected to prevent the on-disk temp file from growing without // bound. Defaults to 4 GiB. var MaxZipBufferSize int64 = 4 << 30 func NewZipExtractor() *zipExtractor { return &zipExtractor{NewBaseExtractor("zip", []string{".zip"})} } // Extract extracts a zip archive into destDir func (z *zipExtractor) Extract(r io.Reader, destDir string) error { // Zip.NewReader requires io.ReaderAt + size // Buffer input into a temp file, bounded by MaxZipBufferSize to // prevent a zip bomb from filling the disk with the (still // compressed) archive while we try to open it. tmpFile, err := os.CreateTemp("", "goget-zip-*.tmp") if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) // Cleanup temp file after completion // LimitReader returns EOF after MaxZipBufferSize+1 bytes, which lets // us detect "input exceeded the limit" by stat'ing the temp file. if _, err := io.Copy(tmpFile, io.LimitReader(r, MaxZipBufferSize+1)); err != nil { tmpFile.Close() return fmt.Errorf("failed to buffer zip: %w", err) } if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } info, err := os.Stat(tmpPath) if err != nil { return fmt.Errorf("failed to stat temp file: %w", err) } if info.Size() > MaxZipBufferSize { return fmt.Errorf("zip archive exceeds maximum size of %d bytes (possible zip bomb)", MaxZipBufferSize) } // Open zip reader (zip.OpenReader detects size automatically) zipReader, err := zip.OpenReader(tmpPath) if err != nil { return fmt.Errorf("failed to open zip archive: %w", err) } defer zipReader.Close() // Extract each file from archive for _, file := range zipReader.File { if err := z.extractFile(file, destDir); err != nil { return fmt.Errorf("failed to extract %s: %w", file.Name, err) } } return nil } // extractFile extracts a single file from zip archive func (z *zipExtractor) extractFile(file *zip.File, destDir string) error { // Sanitize path to prevent zip-slip attacks cleanName := filepath.Clean(file.Name) if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) { return fmt.Errorf("unsafe path in zip: %s", file.Name) } targetPath := filepath.Join(destDir, cleanName) // Handle directories if file.FileInfo().IsDir() { return os.MkdirAll(targetPath, 0755) } // Reject files whose declared uncompressed size exceeds the limit. // The central directory declares this for every file; a value larger // than MaxZipInputSize is a strong zip-bomb signal and we refuse the // whole archive (caller can decide whether to retry with a different // policy). if file.UncompressedSize64 > uint64(MaxZipInputSize) { return fmt.Errorf("file %s too large: %d bytes uncompressed (max %d)", file.Name, file.UncompressedSize64, MaxZipInputSize) } // Create parent directories if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return err } // Open file in archive (automatically decompresses if compressed) rc, err := file.Open() if err != nil { return err } defer rc.Close() // Create output file with original permissions (mask setuid/setgid/sticky) mode := file.Mode() & 0777 outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } defer outFile.Close() // io.LimitReader is a safety net for archives whose central directory // lies about UncompressedSize64 (or leaves it at 0). The primary // defence is the UncompressedSize64 check above. if _, err := io.Copy(outFile, io.LimitReader(rc, MaxZipInputSize)); err != nil { return err } // Restore timestamps if err := os.Chtimes(targetPath, file.Modified, file.Modified); err != nil { // Non-fatal, ignore error } return nil } func init() { Register(NewZipExtractor()) }