diff --git a/mfer/builder.go b/mfer/builder.go index 22d4d4a..7864897 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -3,13 +3,47 @@ package mfer import ( "crypto/sha256" "errors" + "fmt" "io" + "strings" "sync" "time" + "unicode/utf8" "github.com/multiformats/go-multihash" ) +// ValidatePath checks that a file path conforms to manifest path invariants: +// - Must be valid UTF-8 +// - Must use forward slashes only (no backslashes) +// - Must be relative (no leading /) +// - Must not contain ".." segments +// - Must not contain empty segments (no "//") +// - Must not be empty +func ValidatePath(p string) error { + if p == "" { + return errors.New("path cannot be empty") + } + if !utf8.ValidString(p) { + return fmt.Errorf("path %q is not valid UTF-8", p) + } + if strings.ContainsRune(p, '\\') { + return fmt.Errorf("path %q contains backslash; use forward slashes only", p) + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("path %q is absolute; must be relative", p) + } + for _, seg := range strings.Split(p, "/") { + if seg == "" { + return fmt.Errorf("path %q contains empty segment", p) + } + if seg == ".." { + return fmt.Errorf("path %q contains '..' segment", p) + } + } + return nil +} + // RelFilePath represents a relative file path within a manifest. type RelFilePath string @@ -74,6 +108,10 @@ func (b *Builder) AddFile( reader io.Reader, progress chan<- FileHashProgress, ) (FileSize, error) { + if err := ValidatePath(string(path)); err != nil { + return 0, err + } + // Create hash writer h := sha256.New() @@ -96,6 +134,11 @@ func (b *Builder) AddFile( } } + // Verify actual bytes read matches declared size + if totalRead != size { + return totalRead, fmt.Errorf("size mismatch for %q: declared %d bytes but read %d bytes", path, size, totalRead) + } + // Encode hash as multihash (SHA2-256) mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256) if err != nil { @@ -141,8 +184,8 @@ func (b *Builder) FileCount() int { // This is useful when the hash is already known (e.g., from an existing manifest). // Returns an error if path is empty, size is negative, or hash is nil/empty. func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime, hash Multihash) error { - if path == "" { - return errors.New("path cannot be empty") + if err := ValidatePath(string(path)); err != nil { + return err } if size < 0 { return errors.New("size cannot be negative") diff --git a/mfer/constants.go b/mfer/constants.go index f38c74a..4640637 100644 --- a/mfer/constants.go +++ b/mfer/constants.go @@ -3,4 +3,9 @@ package mfer const ( Version = "0.1.0" ReleaseDate = "2025-12-17" + + // MaxDecompressedSize is the maximum allowed size of decompressed manifest + // data (256 MB). This prevents decompression bombs from consuming excessive + // memory. + MaxDecompressedSize int64 = 256 * 1024 * 1024 ) diff --git a/mfer/deserialize.go b/mfer/deserialize.go index 76a8655..878bf8f 100644 --- a/mfer/deserialize.go +++ b/mfer/deserialize.go @@ -76,10 +76,20 @@ func (m *manifest) deserializeInner() error { } defer zr.Close() - dat, err := io.ReadAll(zr) + // Limit decompressed size to prevent decompression bombs. + // Use declared size + 1 byte to detect overflow, capped at MaxDecompressedSize. + maxSize := MaxDecompressedSize + if m.pbOuter.Size > 0 && m.pbOuter.Size < int64(maxSize) { + maxSize = int64(m.pbOuter.Size) + 1 + } + limitedReader := io.LimitReader(zr, maxSize) + dat, err := io.ReadAll(limitedReader) if err != nil { return err } + if int64(len(dat)) >= MaxDecompressedSize { + return fmt.Errorf("decompressed data exceeds maximum allowed size of %d bytes", MaxDecompressedSize) + } isize := len(dat) if int64(isize) != m.pbOuter.Size { diff --git a/mfer/mf.proto b/mfer/mf.proto index d8a5bac..66748e0 100644 --- a/mfer/mf.proto +++ b/mfer/mf.proto @@ -46,6 +46,9 @@ message MFFileOuter { message MFFilePath { // required attributes: + // Path invariants: must be valid UTF-8, use forward slashes only, + // be relative (no leading /), contain no ".." segments, and no + // empty segments (no "//"). string path = 1; int64 size = 2;