142 lines
4.4 KiB
Go
142 lines
4.4 KiB
Go
//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()) }
|