Files
meshcore-analyzer/cmd/server/decoder_bounds_test.go
Kpa-clawbot 85e97d2f37 fix(#1211): bounds-check path length to prevent slice [218:15] panic in MQTT decode (#1214)
**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>
2026-05-15 22:34:21 -07:00

141 lines
5.5 KiB
Go

package main
import (
"encoding/hex"
"strings"
"testing"
)
// --- Issue #1211 round-1 protocol-correctness regressions ---
//
// Background: the round-0 PR added a bounds check on `offset > len(buf)` AFTER
// decodePath returned, but did NOT enforce the firmware-level validity rules
// for pathByte:
//
// firmware/src/Packet.cpp:13-18 — isValidPathLen():
// hash_count = path_len & 63
// hash_size = (path_len >> 6) + 1
// hash_size == 4 is RESERVED — invalid
// hash_count * hash_size MUST be <= MAX_PATH_SIZE (64) [MeshCore.h:21]
//
// firmware/src/MeshCore.h:19 — MAX_PACKET_PAYLOAD = 184
//
// A malformed pathByte=0xF6 (hash_size=4, hash_count=54) inside a buffer LARGE
// ENOUGH to hold 216 path bytes would slip past the round-0 bounds check and
// pollute analytics with a bogus "decoded" packet. Similarly, payloads
// exceeding MAX_PACKET_PAYLOAD should be rejected — firmware would never
// produce them on the wire.
// TestDecodePacketRejectsReservedHashSize_Issue1211 — pathByte=0xF6:
// hash_size = (0xF6 >> 6) + 1 = 3 + 1 = 4 ← RESERVED per firmware
// hash_count = 0xF6 & 0x3F = 54
// total path bytes = 4 * 54 = 216
// We provide a buffer with 216 path bytes available, so the round-0 OOB
// guard PASSES — only the firmware-derived isValidPathLen check catches this.
func TestDecodePacketRejectsReservedHashSize_Issue1211(t *testing.T) {
// header (1) + pathByte (1) + 216 path bytes + 8-byte payload = 226 bytes
raw := "12F6" + strings.Repeat("AB", 216) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting reserved hash_size=4 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "path") {
t.Errorf("error should mention path; got %q", err)
}
}
// TestDecodePacketRejectsOversizedPath_Issue1211 — pathByte=0xBF:
// hash_size = (0xBF >> 6) + 1 = 2 + 1 = 3
// hash_count = 0xBF & 0x3F = 63
// total path bytes = 3 * 63 = 189 > MAX_PATH_SIZE (64) ← INVALID per firmware
// Buffer holds all 189 path bytes — only the firmware check rejects.
func TestDecodePacketRejectsOversizedPath_Issue1211(t *testing.T) {
raw := "12BF" + strings.Repeat("AB", 189) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting hash_count*hash_size > 64 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
}
}
// TestDecodePacketRejectsOversizedPayload_Issue1211 — payload exceeds
// MAX_PACKET_PAYLOAD (184). Firmware (MeshCore.h:19) cannot emit such a
// packet on the wire; the decoder should drop it rather than emit a bogus
// "successfully decoded" record.
func TestDecodePacketRejectsOversizedPayload_Issue1211(t *testing.T) {
// hash_size=1, hash_count=0 → no path bytes, then 200-byte payload
// header=0x12 (DIRECT/ADVERT), pathByte=0x00 → no hops, then payload
// payload length 200 > 184 → must reject
raw := "1200" + strings.Repeat("AA", 200)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting payload > MAX_PACKET_PAYLOAD=184 (firmware MeshCore.h:19); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "payload") {
t.Errorf("error should mention payload; got %q", err)
}
}
// TestDecodePath_RejectsReservedHashSize_Issue1211 — adversarial M1: push the
// invalid-path check INTO decodePath so callers can't accidentally rely on the
// downstream OOB guard. Direct unit test on decodePath.
func TestDecodePath_RejectsReservedHashSize_Issue1211(t *testing.T) {
// Plenty of buffer — 216 bytes — so the *test* doesn't hide the check
// behind an OOB failure.
buf := make([]byte, 216)
for i := range buf {
buf[i] = 0xAB
}
_, _, err := decodePath(0xF6, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject pathByte=0xF6 (hash_size=4 reserved); got nil err")
}
}
func TestDecodePath_RejectsOversizedPath_Issue1211(t *testing.T) {
buf := make([]byte, 189)
_, _, err := decodePath(0xBF, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject hash_count*hash_size=189 > MAX_PATH_SIZE=64; got nil err")
}
}
func TestDecodePath_AcceptsValidEncodings_Issue1211(t *testing.T) {
// hash_size=1, hash_count=5 → 5 path bytes — valid per firmware.
buf := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
path, consumed, err := decodePath(0x05, buf, 0)
if err != nil {
t.Fatalf("decodePath rejected valid encoding: %v", err)
}
if consumed != 5 {
t.Errorf("consumed=%d, want 5", consumed)
}
if path.HashCount != 5 || path.HashSize != 1 {
t.Errorf("decode wrong: hashCount=%d hashSize=%d", path.HashCount, path.HashSize)
}
}
// Pin the round-0 tautological assertion. Specific error phrasing required.
// (Kent #1) — `TestDecodePacketBoundsFromWire_Issue1211` used to assert only
// `err != nil`; a generic recover would have passed. Now must contain
// "path length" AND "exceeds buffer".
//
// Use pathByte=0x0A (hash_size=1, hash_count=10) — firmware-VALID encoding
// that claims 10 path bytes; buffer only has 5 → the OOB guard fires (not
// the validity check). This pins the OOB error string specifically.
func TestDecodePacketBoundsFromWireErrorPhrasing_Issue1211(t *testing.T) {
raw := "120A" + strings.Repeat("AA", 5)
_, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "path length") {
t.Errorf("error missing 'path length'; got %q", err)
}
if !strings.Contains(err.Error(), "exceeds buffer") {
t.Errorf("error missing 'exceeds buffer'; got %q", err)
}
}
// silence unused import in case of future trimming
var _ = hex.EncodeToString