From ebaf2a65caad5e8005f911252733d3ac32d6caef Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:35:07 +0100 Subject: [PATCH 1/3] Fix AddFile to verify actual bytes read matches declared size (closes #25) (#30) After reading file content, verify `totalRead == size` and return an error on mismatch. Co-authored-by: clawbot Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/30 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/builder.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mfer/builder.go b/mfer/builder.go index 22d4d4a..db605e2 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -3,6 +3,7 @@ package mfer import ( "crypto/sha256" "errors" + "fmt" "io" "sync" "time" @@ -96,6 +97,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 { From 2efffd9da86b7fd34ca5d0ab9dfde79365e8997a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:45:29 +0100 Subject: [PATCH 2/3] Specify and enforce path invariants (closes #26) (#31) Add `ValidatePath()` enforcing UTF-8, forward-slash, relative, no `..`, no empty segments. Applied in `AddFile` and `AddFileWithHash`. Proto comments document the rules. Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/31 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/builder.go | 41 +++++++++++++++++++++++++++++++++++++++-- mfer/mf.proto | 3 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/mfer/builder.go b/mfer/builder.go index db605e2..7864897 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -5,12 +5,45 @@ import ( "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 @@ -75,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() @@ -147,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/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; From 7144617d0ed1e39c198b9f18526e603d1cae0a8e Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Feb 2026 01:45:55 +0100 Subject: [PATCH 3/3] Add decompression size limit in deserializeInner() (closes #24) (#29) Wrap zstd decompressor with `io.LimitReader` (256MB max) to prevent decompression bombs. Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/mfer/pulls/29 Co-authored-by: clawbot Co-committed-by: clawbot --- mfer/constants.go | 5 +++++ mfer/deserialize.go | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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 {