Files

142 lines
4.4 KiB
Go
Raw Permalink Normal View History

//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()) }