mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 00:01:21 +00:00
**RED commit:** `65d9f57b` (CI run will appear at https://github.com/Kpa-clawbot/CoreScope/actions after PR opens) Fixes #1211 ## Root cause `decodePath()` returns `bytesConsumed = hash_size * hash_count` where both come straight from the wire-supplied `pathByte` (upper 2 bits → `hash_size`, lower 6 bits → `hash_count`). Max claimable: 4 × 63 = 252 bytes. A malformed packet on the wire claimed `pathByte=0xF6` (hash_size=4, hash_count=54 → 216 path bytes) inside a 15-byte buffer. The inner hop-extraction loop in `decodePath` did break early on overflow — but `bytesConsumed` was still returned at face value (216). `DecodePacket` then did `offset += 216` (offset=218) and `payloadBuf := buf[offset:]` panicked with the prod-observed signature: ``` runtime error: slice bounds out of range [218:15] ``` The handler-level `defer/recover` at `cmd/ingestor/main.go:258-263` caught it, but the message was silently dropped with no usable diagnostic. ## Fix Add a `if offset > len(buf)` guard at BOTH decoder sites (same pattern, same panic potential): - `cmd/ingestor/decoder.go` — DecodePacket after decodePath - `cmd/server/decoder.go` — DecodePacket after decodePath Return a descriptive error citing the claimed length and pathByte hex so operators can reproduce. Also: `cmd/ingestor/main.go` decode-error log now includes `topic`, `observer`, and `rawHexLen` so future malformed packets are reproducible without needing to attach a debugger. ## Tests (TDD red → green) Both packages got two new tests: - **`TestDecodePacketBoundsFromWire_Issue1211`** — feeds the exact wire shape from the prod log (`pathByte=0xF6` inside a 15-byte buf). Asserts `DecodePacket` does NOT panic and returns an error. - **`TestDecodePacketFuzzTruncated_Issue1211`** — sweeps every `(header, pathByte)` combination with tails 0..19 bytes (≈1.3M inputs). Asserts zero panics. ### Red commit proof On commit `65d9f57b` (RED), both tests fail with the panic: ``` === RUN TestDecodePacketBoundsFromWire_Issue1211 decoder_test.go:1996: DecodePacket panicked on malformed input: runtime error: slice bounds out of range [218:15] --- FAIL: TestDecodePacketBoundsFromWire_Issue1211 (0.00s) === RUN TestDecodePacketFuzzTruncated_Issue1211 decoder_test.go:2010: DecodePacket panicked during fuzz: runtime error: slice bounds out of range [3:2] --- FAIL: TestDecodePacketFuzzTruncated_Issue1211 (0.01s) ``` On commit `7a6ae52c` (GREEN), full suites pass: - `cmd/ingestor`: `ok 53.988s` - `cmd/server`: `ok 29.456s` ## Acceptance criteria - [x] Identify the slice op producing `[218:15]` — `payloadBuf := buf[offset:]` in `DecodePacket` (decoder.go), where `offset` had been advanced by an unchecked `bytesConsumed` from `decodePath()`. - [x] Bounds check added at the identified site(s) — both ingestor and server decoders. - [x] Test with crafted payload (length-field > remaining buffer) — `TestDecodePacketBoundsFromWire_Issue1211`. - [x] Log topic, observer ID, payload byte length on drop — updated `MQTT [%s] decode error` log line. - [x] Existing tests stay green — confirmed both packages. ## Out of scope Reconnect-after-disconnect (#1212) — handled by a separate subagent. This PR touches NO reconnect logic. --------- Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: corescope-bot <bot@corescope>
This commit is contained in:
+49
-3
@@ -144,9 +144,35 @@ func decodeHeader(b byte) Header {
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
// Firmware-derived limits — see firmware/src/MeshCore.h:19,21.
|
||||
const (
|
||||
maxPathSize = 64 // MAX_PATH_SIZE — total path bytes allowed
|
||||
maxPacketPayload = 184 // MAX_PACKET_PAYLOAD — max raw payload bytes
|
||||
)
|
||||
|
||||
// isValidPathLen mirrors firmware Packet::isValidPathLen
|
||||
// (firmware/src/Packet.cpp:13-18). hash_size==4 is reserved; total path bytes
|
||||
// must fit within MAX_PATH_SIZE.
|
||||
func isValidPathLen(pathByte byte) bool {
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
if hashSize == 4 {
|
||||
return false // reserved
|
||||
}
|
||||
return hashCount*hashSize <= maxPathSize
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
// Exact mirror of firmware Packet::isValidPathLen (Packet.cpp:13-18).
|
||||
// hash_size==4 is reserved and is rejected by firmware regardless of
|
||||
// hash_count, so we must reject 0xC0 etc even on zero-hop packets —
|
||||
// firmware never emits them, so an on-wire pathByte with the upper
|
||||
// 2 bits set to 11 is by definition malformed/adversarial.
|
||||
if !isValidPathLen(pathByte) {
|
||||
return Path{}, 0, fmt.Errorf("invalid path encoding: pathByte 0x%02X (hash_size=%d hash_count=%d) violates firmware validity (Packet.cpp:13-18, MAX_PATH_SIZE=%d)", pathByte, hashSize, hashCount, maxPathSize)
|
||||
}
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
@@ -163,7 +189,7 @@ func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}, totalBytes, nil
|
||||
}
|
||||
|
||||
// isTransportRoute delegates to packetpath.IsTransportRoute.
|
||||
@@ -261,6 +287,13 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
name := string(appdata[off:])
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
name = sanitizeName(name)
|
||||
// Firmware writes the node name into a 32-byte buffer
|
||||
// (MAX_ADVERT_DATA_SIZE, firmware/src/MeshCore.h:11). Truncate
|
||||
// here so adversarial on-wire adverts can't pollute Payload.Name
|
||||
// with bytes firmware would never emit.
|
||||
if len(name) > 32 {
|
||||
name = name[:32]
|
||||
}
|
||||
p.Name = name
|
||||
}
|
||||
}
|
||||
@@ -385,10 +418,23 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
path, bytesConsumed, decodeErr := decodePath(pathByte, buf, offset)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
offset += bytesConsumed
|
||||
|
||||
// Bounds check — see cmd/ingestor/decoder.go for full rationale (#1211).
|
||||
if offset > len(buf) {
|
||||
return nil, fmt.Errorf("packet path length (%d bytes claimed by pathByte 0x%02X) exceeds buffer (%d bytes)", bytesConsumed, pathByte, len(buf))
|
||||
}
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
// Firmware caps payload at MAX_PACKET_PAYLOAD=184 (firmware/src/MeshCore.h:19).
|
||||
// Anything larger cannot be a valid wire packet — drop it.
|
||||
if len(payloadBuf) > maxPacketPayload {
|
||||
return nil, fmt.Errorf("packet payload (%d bytes) exceeds firmware MAX_PACKET_PAYLOAD=%d (MeshCore.h:19)", len(payloadBuf), maxPacketPayload)
|
||||
}
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
|
||||
Reference in New Issue
Block a user