mirror of
https://github.com/luxfi/zap.git
synced 2026-07-26 22:53:31 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user