feat(zap): v0.7.2 — Object.ListStride per-stride clamp + List.Len SAFETY doc + FuzzParse

LP-023 Red round 3 follow-ups:

NEW-V1 follow-up: tighter per-element stride clamp via Object.ListStride.
The bare Object.List uses the permissive `length <= len(data)` baseline
(wire layer cannot know per-accessor stride). Callers that know the
element width can opt into the tighter `length * minStride <= bufRem`
clamp via ListStride(off, stride), rejecting poisoned length values up
front instead of relying on per-element bounds checks.

NEW-V1 docstring: List.Len() now carries the SAFETY caveat — callers MUST
NOT pre-allocate via make([]T, l.Len()) without an independent bound.

FuzzParse (Red follow-up #3): round-trip property fuzzer pinning the
Parse↔Bytes contract: msg.Bytes() == data[:msg.Size()], Parse is
idempotent on its own output, Version() always in {Version1, Version2}.
1M+ execs clean on M1 Max with 20s fuzztime; seed corpus includes
adversarial buffers that exercised RED-HIGH-1/2/3.

Tests:
- TestNewV1_ListStrideTighterClamp — poisoned length=100 passes bare
  baseline but ListStride(0, 4) rejects (400 > bufRem)
- TestNewV1_ListStrideAcceptsHonestLength — honest length=5 stride=4
  buffer must pass

All existing tests still pass (RedRound2 HIGH-1/2/3, MEDIUM-1, V18 + base).

Wire format unchanged — purely a tightened acceptance test; backward
compatible at the wire level. Callers update obj.List(off) →
obj.ListStride(off, minStride) per element type.
This commit is contained in:
Hanzo AI
2026-06-02 15:31:41 -07:00
parent 9d86b95754
commit 3fac1c82bf
3 changed files with 248 additions and 1 deletions
+99
View File
@@ -19,6 +19,105 @@ func buildSeedMessage(fields func(ob *ObjectBuilder)) []byte {
return b.Finish()
}
// FuzzParse is a round-trip property fuzzer for Parse:
//
// Properties (all hold for Parse-accepted buffers):
//
// 1. msg.Bytes() == data[:msg.Size()] — Parse holds no derived
// buffer; Bytes() is exactly the declared-size prefix of the input.
// 2. msg.Size() >= HeaderSize && msg.Size() <= len(data)
// — Parse cannot extend past what the caller passed in.
// 3. Parse(msg.Bytes()) succeeds and yields a message with identical
// Bytes() — Parse is idempotent on its own output.
//
// Rejected buffers must return a typed error, never panic. LP-023 Red
// round 3 follow-up #3. Complements FuzzZAPParse (accessor panic-safety on
// arbitrary inputs) by pinning the Parse↔Bytes contract.
func FuzzParse(f *testing.F) {
// Seed corpus: every valid construction we can think of, plus the
// adversarial buffers that exercised RED-HIGH-1/2/3.
f.Add(buildSeedMessage(func(ob *ObjectBuilder) { ob.SetUint64(0, 0xDEADBEEF) }))
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetUint32(0, 42)
ob.SetText(4, "round-trip")
}))
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetBool(0, true)
ob.SetBytes(4, []byte{0xCA, 0xFE, 0xBA, 0xBE})
}))
// Adversarial seeds: corrupted header bytes that should fail Parse.
f.Add([]byte{})
f.Add([]byte{0x5A, 0x41, 0x50, 0x00}) // magic only
{
hdr := make([]byte, HeaderSize)
copy(hdr[0:4], Magic)
binary.LittleEndian.PutUint16(hdr[4:6], 99)
binary.LittleEndian.PutUint32(hdr[12:16], HeaderSize)
f.Add(hdr)
}
// Seed with an empty list (length=0, offset=0).
f.Add(buildSeedMessage(func(ob *ObjectBuilder) {
ob.SetList(0, 0, 0)
ob.SetUint64(8, 1)
}))
// Seed with a length-prefixed list (post-RED-HIGH-1 clamp valid).
{
b := NewBuilder(256)
lb := b.StartList(4)
for i := 0; i < 4; i++ {
lb.AddBytes([]byte{0x00, 0x00, 0x00, byte(i)})
}
listOff, listLen := lb.Finish()
ob := b.StartObject(16)
ob.SetList(0, listOff, listLen)
ob.FinishAsRoot()
f.Add(b.Finish())
}
f.Fuzz(func(t *testing.T, data []byte) {
msg, err := Parse(data)
if err != nil {
// Property: Parse returns a typed error (not a panic) on bad
// inputs. The error itself is opaque to this property — only
// that we got HERE (no panic) matters. Sentinel error values
// are exercised by unit tests, not fuzz.
return
}
// Property 1: Bytes() == data[:Size()]. Parse stores no copy; the
// returned slice MUST alias data[:declaredSize].
size := msg.Size()
if size > len(data) {
t.Fatalf("msg.Size()=%d > len(data)=%d (Parse accepted truncated buffer)", size, len(data))
}
got := msg.Bytes()
if !bytes.Equal(got, data[:size]) {
t.Fatalf("Parse->Bytes mismatch: got len=%d want data[:%d]=len(%d)", len(got), size, size)
}
// Property 2: re-parse of Bytes() succeeds and pins Size() to its
// own length (idempotent — second parse cannot keep shrinking).
msg2, err2 := Parse(got)
if err2 != nil {
t.Fatalf("Parse(Bytes()) failed: %v", err2)
}
if msg2.Size() != size {
t.Fatalf("Parse(Bytes()).Size()=%d != msg.Size()=%d (not idempotent)", msg2.Size(), size)
}
got2 := msg2.Bytes()
if !bytes.Equal(got2, got) {
t.Fatalf("Parse->Bytes->Parse->Bytes drift")
}
// Property 3: Version() returns one of the accepted values
// (Parse-side gate already enforced; this catches regressions).
v := msg.Version()
if v != Version1 && v != Version2 {
t.Fatalf("Parse accepted bad version: %d", v)
}
})
}
// FuzzZAPParse feeds arbitrary bytes to Parse. It must never panic regardless
// of input. Every returned error is acceptable; every non-error result must
// produce a valid Message with accessible root.
+67 -1
View File
@@ -371,6 +371,61 @@ func (o Object) List(fieldOffset int) List {
return List{msg: o.msg, offset: absOffset, length: int(length)}
}
// ListStride is List() with a caller-supplied per-element stride hint. It
// applies the tighter clamp `length * minStride <= len(buffer) - absOffset`
// up front, rejecting attacker-set length=0xFFFFFFFF on multi-byte-stride
// accessors instead of pushing the bounds check to every per-element
// accessor.
//
// Use case: an Uint32 list with stride 4, a struct list with stride 96 — pass
// the stride; the wire layer rejects length values that exceed what the
// remaining buffer can possibly carry. This is a NEW-V1 follow-up (LP-023
// Red round 3) — the bare List() accessor cannot know the stride and uses
// the permissive `length <= len(data)` baseline.
//
// minStride MUST be the BYTE width of one element (1 for uint8, 4 for
// uint32, 8 for uint64, SizeTransferableOutput for OutputList, etc.). When
// minStride <= 0 the call falls back to bare List() semantics.
//
// Wire format is unchanged — same {relOffset, length} pair as List(). The
// clamp is purely a tightened acceptance test; any List() that would
// succeed with minStride=0 succeeds with the correct stride too.
func (o Object) ListStride(fieldOffset int, minStride uint32) List {
pos := o.offset + fieldOffset
if pos+8 > len(o.msg.data) {
return List{}
}
relOffset := int32(binary.LittleEndian.Uint32(o.msg.data[pos:]))
if relOffset == 0 {
return List{} // Null
}
length := binary.LittleEndian.Uint32(o.msg.data[pos+4:])
absOffset := pos + int(relOffset)
if absOffset < HeaderSize || absOffset >= len(o.msg.data) {
return List{}
}
// Tighter clamp using per-element stride: `length * minStride` must fit
// in the remaining buffer after absOffset. This rejects 0xFFFFFFFF DoS
// on any stride > 1 immediately, instead of waiting for per-element
// access bounds checks. Length<=msgsize baseline (RED-HIGH-1) is also
// applied for stride=0 (or unspecified caller).
bufRem := uint64(len(o.msg.data) - absOffset)
if minStride > 0 {
// uint64 product cannot overflow because both operands are uint32.
if uint64(length)*uint64(minStride) > bufRem {
return List{}
}
} else if uint64(length) > uint64(len(o.msg.data)) {
return List{}
}
return List{msg: o.msg, offset: absOffset, length: int(length)}
}
// List is a zero-copy view into a ZAP list.
type List struct {
msg *Message
@@ -378,7 +433,18 @@ type List struct {
length int
}
// Len returns the number of elements.
// Len returns the list element count as encoded on the wire.
//
// SAFETY: callers MUST NOT pre-allocate via make([]T, l.Len()) without an
// independent bound. The wire encoding only constrains length to len(buffer),
// so a 64KB mempool tx can carry Len()=65535 — large enough to OOM if a
// consumer naively pre-allocates. Always iterate List.At(i) with i < Len()
// AND validate each element's invariants before trusting the count.
//
// For tighter per-stride bounds at the wire layer, use Object.ListStride
// (introduced in v0.7.2): it rejects length*minStride > len(buffer) up
// front. This Len() value is the wire-encoded count irrespective of which
// accessor produced the List — Object.List or Object.ListStride.
func (l List) Len() int {
return l.length
}
+82
View File
@@ -481,6 +481,88 @@ func TestRedRound2_HIGH1_UncappedListLength(t *testing.T) {
}
}
// TestNewV1_ListStrideTighterClamp pins the per-element-stride clamp
// (NEW-V1 follow-up, LP-023 Red round 3). A poisoned length field that
// passes the permissive `length <= len(data)` baseline at stride 0 gets
// rejected at the tighter `length*stride <= bufRem` bound when the caller
// passes the correct minStride. We craft the buffer so the poisoned
// length satisfies `length <= len(data)` (bare List accepts) but
// `length * 4 > bufRem` (ListStride rejects).
func TestNewV1_ListStrideTighterClamp(t *testing.T) {
b := NewBuilder(512)
lb := b.StartList(4)
for i := 0; i < 32; i++ {
lb.AddUint32(uint32(i))
}
listOff, listLen := lb.Finish()
ob := b.StartObject(8)
ob.SetList(0, listOff, listLen)
ob.FinishAsRoot()
buf := b.Finish()
rootOffset := int(binary.LittleEndian.Uint32(buf[8:12]))
// Poison length: pick L such that L <= len(buf) (bare passes) but
// L*4 > bufRem after absOffset (ListStride rejects). len(buf) ~ 200.
// Bare clamp: int(length) > len(o.msg.data) → reject. So we need
// length <= len(buf). bufRem after list start is ~ len(buf) - absOffset
// (~70). 4 * 100 = 400 > 70 — easy reject. Length=100 satisfies bare
// (100 <= 200) but fails stride*length (400 > 70).
binary.LittleEndian.PutUint32(buf[rootOffset+4:], 100)
msg, err := Parse(buf)
if err != nil {
t.Fatalf("Parse rejected: %v", err)
}
// Bare List() accepts length=100 since 100 <= len(buf) (~200).
bareList := msg.Root().List(0)
if bareList.Len() != 100 {
t.Fatalf("bare List() expected len=100 (permissive baseline), got %d (len(buf)=%d)", bareList.Len(), len(buf))
}
// ListStride(0, 4) rejects: 100 * 4 = 400 > bufRem (~70).
stridedList := msg.Root().ListStride(0, 4)
if !stridedList.IsNull() {
t.Fatalf("ListStride(0, 4) regression: poisoned length=100 stride=4 not rejected; Len()=%d", stridedList.Len())
}
}
// TestNewV1_ListStrideAcceptsHonestLength pins the false-positive guard:
// honest lists with stride and length matching buffer must pass the
// tighter clamp.
func TestNewV1_ListStrideAcceptsHonestLength(t *testing.T) {
b := NewBuilder(256)
lb := b.StartList(4)
for i := 0; i < 5; i++ {
lb.AddUint32(uint32(0xAA00 + i))
}
listOff, listLen := lb.Finish()
ob := b.StartObject(8)
ob.SetList(0, listOff, listLen)
ob.FinishAsRoot()
buf := b.Finish()
msg, err := Parse(buf)
if err != nil {
t.Fatalf("Parse rejected: %v", err)
}
list := msg.Root().ListStride(0, 4)
if list.IsNull() {
t.Fatalf("ListStride rejected honest length=5 stride=4 buffer")
}
if list.Len() != 5 {
t.Fatalf("ListStride Len()=%d want 5", list.Len())
}
for i := 0; i < 5; i++ {
got := list.Uint32(i)
want := uint32(0xAA00 + i)
if got != want {
t.Errorf("element %d: got %x want %x", i, got, want)
}
}
}
// TestRedRound2_HIGH2_BackwardListPointer pins the Red repro:
// craft a List with relOffset that, under signed decoding, points into the
// wire header (Magic bytes). After the fix, Object.List returns an empty