Fix review findings: hostile snapshots, stale async results, trash safety
- snapshot: depth limit (stack overflow in exporters), decompressed-size limit (gzip bombs), per-node size cap and saturating aggregation - export: sanitise exclusion patterns from snapshot metadata - tui: drop duplicate/trash/filter results that belong to a replaced tree; cancel duplicate search on rescan - trash: refuse symlinked or foreign shared-filesystem trash dirs; never overwrite an existing trashed item - scan: no symlink following without inode identity; correct unscanned count and keep real read errors after cancellation
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/export"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
)
|
||||
|
||||
// craft builds a well-formed (correctly checksummed) but hostile snapshot.
|
||||
func craft(n int, meta string, rec func(i int, w *writer)) []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString(brand.SnapshotMagic)
|
||||
out.Write(binary.LittleEndian.AppendUint16(nil, Version))
|
||||
out.Write(binary.LittleEndian.AppendUint16(nil, flagGzipped))
|
||||
gz, _ := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
||||
h := sha256.New()
|
||||
w := &writer{w: io.MultiWriter(gz, h)}
|
||||
w.uvarint(uint64(len(meta)))
|
||||
w.bytes([]byte(meta))
|
||||
w.uvarint(1)
|
||||
w.str("")
|
||||
w.uvarint(uint64(n))
|
||||
for i := range n {
|
||||
rec(i, w)
|
||||
}
|
||||
w.uvarint(0)
|
||||
gz.Close()
|
||||
out.Write(h.Sum(nil))
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func node(w *writer, pd uint64, kind inventory.Kind, name string, size int64) {
|
||||
w.uvarint(pd)
|
||||
w.bytes([]byte{byte(kind), 0})
|
||||
w.uvarint(0)
|
||||
w.str(name)
|
||||
w.varint(size)
|
||||
w.varint(size)
|
||||
w.varint(0)
|
||||
for range 5 {
|
||||
w.uvarint(0)
|
||||
}
|
||||
}
|
||||
|
||||
func chain(n int) []byte {
|
||||
return craft(n, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, 1, inventory.KindDir, "a", 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeepChainRejected(t *testing.T) {
|
||||
if _, err := Load(bytes.NewReader(chain(MaxDepth + 1))); err != nil {
|
||||
t.Fatalf("depth at the limit must load: %v", err)
|
||||
}
|
||||
_, err := Load(bytes.NewReader(chain(MaxDepth + 2)))
|
||||
if err == nil || !strings.Contains(err.Error(), "deeper") {
|
||||
t.Fatalf("over-deep chain accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecompressionBombLimited(t *testing.T) {
|
||||
flat := craft(100_000, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, uint64(i), inventory.KindFile, "f", 0)
|
||||
}
|
||||
})
|
||||
_, err := LoadLimit(bytes.NewReader(flat), 64<<10)
|
||||
if err == nil || !strings.Contains(err.Error(), "limit") {
|
||||
t.Fatalf("bomb not limited: %v", err)
|
||||
}
|
||||
if got := bodyLimit(int64(len(flat))); got < 64<<20 {
|
||||
t.Fatalf("bodyLimit floor: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHugeSizesDoNotWrap(t *testing.T) {
|
||||
data := craft(3, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, uint64(i), inventory.KindFile, "f", MaxSize)
|
||||
}
|
||||
})
|
||||
tr, err := Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr.Node(0).TotSize < 0 {
|
||||
t.Fatal("aggregate wrapped negative")
|
||||
}
|
||||
over := craft(2, `{"root":"/r"}`, func(i int, w *writer) {
|
||||
if i == 0 {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
} else {
|
||||
node(w, 1, inventory.KindFile, "f", MaxSize+1)
|
||||
}
|
||||
})
|
||||
if _, err := Load(bytes.NewReader(over)); err == nil {
|
||||
t.Fatal("size above MaxSize accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostileMetadataSanitisedInSummary(t *testing.T) {
|
||||
data := craft(1, `{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}`, func(i int, w *writer) {
|
||||
node(w, 0, inventory.KindDir, "", 0)
|
||||
})
|
||||
tr, err := Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Stats.Excluded = 1
|
||||
var b bytes.Buffer
|
||||
export.Summary(&b, tr)
|
||||
if strings.ContainsAny(b.String(), "\x1b\x07") {
|
||||
t.Fatalf("raw control characters in summary: %q", b.String())
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,18 @@ const Version = 1
|
||||
|
||||
// Limits applied when reading untrusted snapshots.
|
||||
const (
|
||||
MaxNodes = 1<<31 - 1
|
||||
MaxNameLen = 1 << 16
|
||||
MaxMetaLen = 16 << 20
|
||||
MaxExtLen = 64
|
||||
MaxMsgLen = 4096
|
||||
maxExts = 1<<16 - 1
|
||||
flagGzipped = 1
|
||||
MaxNodes = 1<<31 - 1
|
||||
MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters
|
||||
MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing
|
||||
// DefaultMaxBody caps the decompressed body for Load. LoadFile uses a
|
||||
// limit proportional to the file size instead (see bodyLimit).
|
||||
DefaultMaxBody = 4 << 30
|
||||
MaxNameLen = 1 << 16
|
||||
MaxMetaLen = 16 << 20
|
||||
MaxExtLen = 64
|
||||
MaxMsgLen = 4096
|
||||
maxExts = 1<<16 - 1
|
||||
flagGzipped = 1
|
||||
)
|
||||
|
||||
type meta struct {
|
||||
@@ -305,8 +310,35 @@ func (r *reader) str(max uint64, what string) string {
|
||||
return string(r.bytesN(r.uvarint(max, what+" length")))
|
||||
}
|
||||
|
||||
// Load reads and validates a snapshot.
|
||||
func Load(in io.Reader) (*inventory.Tree, error) {
|
||||
// limitedReader fails (rather than silently truncating) past n bytes, so a
|
||||
// decompression bomb is reported as such.
|
||||
type limitedReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
if l.n <= 0 {
|
||||
return 0, errors.New("snapshot: decompressed size exceeds limit (possible decompression bomb)")
|
||||
}
|
||||
if int64(len(p)) > l.n {
|
||||
p = p[:l.n]
|
||||
}
|
||||
n, err := l.r.Read(p)
|
||||
l.n -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// bodyLimit allows decompressed bodies up to 64× the compressed file size
|
||||
// (real snapshots compress ~6×), with a 64 MiB floor for tiny files.
|
||||
func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) }
|
||||
|
||||
// Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes
|
||||
// of decompressed data.
|
||||
func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) }
|
||||
|
||||
// LoadLimit is Load with an explicit decompressed-size limit.
|
||||
func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) {
|
||||
br := bufio.NewReaderSize(in, 1<<20)
|
||||
hdr := make([]byte, len(brand.SnapshotMagic)+4)
|
||||
if _, err := io.ReadFull(br, hdr); err != nil {
|
||||
@@ -333,7 +365,7 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
||||
pr, pw := io.Pipe()
|
||||
defer pr.Close() // unblocks the copier if we bail out early
|
||||
go func() {
|
||||
_, err := io.CopyBuffer(pw, io.TeeReader(gz, h), make([]byte, 256<<10))
|
||||
_, err := io.CopyBuffer(pw, io.TeeReader(&limitedReader{gz, maxBody}, h), make([]byte, 256<<10))
|
||||
pw.CloseWithError(err)
|
||||
}()
|
||||
r := &reader{r: bufio.NewReaderSize(pr, 1<<16)}
|
||||
@@ -358,6 +390,7 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
||||
}
|
||||
extIndex := t.ExtIndexer()
|
||||
nNodes := r.uvarint(MaxNodes, "node count")
|
||||
depth := []uint16{0} // per node, for the depth limit; parents precede children
|
||||
if nNodes == 0 {
|
||||
return nil, errors.New("snapshot: no root node")
|
||||
}
|
||||
@@ -380,8 +413,8 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
||||
r.fail("node %d: invalid kind/category", i)
|
||||
break
|
||||
}
|
||||
if size < 0 || alloc < 0 {
|
||||
r.fail("node %d: negative size", i)
|
||||
if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize {
|
||||
r.fail("node %d: size out of range", i)
|
||||
break
|
||||
}
|
||||
var id inventory.NodeID
|
||||
@@ -407,7 +440,12 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
||||
r.fail("node %d: invalid name %q", i, name)
|
||||
break
|
||||
}
|
||||
if d := depth[parent] + 1; d > MaxDepth {
|
||||
r.fail("node %d: deeper than %d levels", i, MaxDepth)
|
||||
break
|
||||
}
|
||||
id = t.Add(parent, name, kind)
|
||||
depth = append(depth, depth[parent]+1)
|
||||
}
|
||||
n := t.Node(id)
|
||||
n.Cat, n.Flags = cat, flags
|
||||
@@ -469,5 +507,9 @@ func LoadFile(path string) (*inventory.Tree, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return Load(f)
|
||||
limit := int64(DefaultMaxBody)
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
limit = bodyLimit(fi.Size())
|
||||
}
|
||||
return LoadLimit(f, limit)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user