From 92dc6efd4207115fabe28d55a716c9617ac8174b Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 26 Feb 2026 09:52:03 +1100 Subject: [PATCH 1/3] fix: harden RLP decoder with input validation and canonical encoding checks --- chain/types/ethtypes/rlp.go | 175 ++++++++++++++++--------- chain/types/ethtypes/rlp_test.go | 213 ++++++++++++++++++++++++++++--- 2 files changed, 308 insertions(+), 80 deletions(-) diff --git a/chain/types/ethtypes/rlp.go b/chain/types/ethtypes/rlp.go index 65801ac45c8..87349f25c7d 100644 --- a/chain/types/ethtypes/rlp.go +++ b/chain/types/ethtypes/rlp.go @@ -1,23 +1,50 @@ package ethtypes import ( - "bytes" "encoding/binary" + "errors" "fmt" "golang.org/x/xerrors" ) -// maxListElements restricts the amount of RLP list elements we'll read. -// The ETH API only ever reads EIP-1559 transactions, which are bounded by -// 12 elements exactly, so we play it safe and set exactly that limit here. -const maxListElements = 12 +const ( + // maxListElements limits the number of elements decoded per RLP list. + // Of the transaction formats we decode, EIP-1559 has the most fields + // at 12 (legacy has 9). This is set to match that upper bound. + maxListElements = 12 + + // maxListDepth limits how deeply RLP lists may be nested. Ethereum + // transactions nest at most 3 levels deep (tx list > access list > + // [address, storageKeys] tuple > keys list). 32 provides generous + // headroom for any future transaction format. + maxListDepth = 32 + + // maxInputLen is the largest RLP blob we will attempt to decode. + // Contract-deploy transactions on Filecoin can carry up to 1 MiB of + // init-code; 2 MiB gives comfortable headroom after RLP framing. + maxInputLen = 2 << 20 // 2 MiB +) + +var ( + errRLPEmptyInput = errors.New("invalid rlp data: data is empty") + errRLPNonCanonicalList = errors.New("invalid rlp data: non-canonical list length encoding") + errRLPNonCanonicalString = errors.New("invalid rlp data: non-canonical string length encoding") + errRLPNonCanonicalByte = errors.New("invalid rlp data: non-canonical single byte encoding") + errRLPNonCanonicalLeading = errors.New("invalid rlp data: non-canonical length encoding with leading zeros") + errRLPListBounds = errors.New("invalid rlp data: out of bound while parsing list") + errRLPStringBounds = errors.New("invalid rlp data: out of bound while parsing string") + errRLPLengthBounds = errors.New("invalid rlp data: out of bound while parsing length") + errRLPIncorrectListLen = errors.New("invalid rlp data: incorrect list length") + errRLPEncodeZeroLength = errors.New("cannot encode length: length should be larger than 0") + errRLPEncodeInvalidType = errors.New("input data should either be a list or a byte array") +) -func EncodeRLP(val interface{}) ([]byte, error) { +func EncodeRLP(val any) ([]byte, error) { return encodeRLP(val) } -func encodeRLPListItems(list []interface{}) (result []byte, err error) { +func encodeRLPListItems(list []any) (result []byte, err error) { res := []byte{} for _, elem := range list { encoded, err := encodeRLP(elem) @@ -29,30 +56,22 @@ func encodeRLPListItems(list []interface{}) (result []byte, err error) { return res, nil } -func encodeLength(length int) (lenInBytes []byte, err error) { +// encodeLength returns the minimal big-endian representation of length. +func encodeLength(length int) ([]byte, error) { if length == 0 { - return nil, fmt.Errorf("cannot encode length: length should be larger than 0") - } - - buf := new(bytes.Buffer) - err = binary.Write(buf, binary.BigEndian, int64(length)) - if err != nil { - return nil, err + return nil, errRLPEncodeZeroLength } - - firstNonZeroIndex := len(buf.Bytes()) - 1 - for i, b := range buf.Bytes() { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], uint64(length)) + for i, b := range buf { if b != 0 { - firstNonZeroIndex = i - break + return append([]byte{}, buf[i:]...), nil } } - - res := buf.Bytes()[firstNonZeroIndex:] - return res, nil + return nil, errRLPEncodeZeroLength } -func encodeRLP(val interface{}) ([]byte, error) { +func encodeRLP(val any) ([]byte, error) { switch data := val.(type) { case []byte: if len(data) == 1 && data[0] <= 0x7f { @@ -70,7 +89,7 @@ func encodeRLP(val interface{}) ([]byte, error) { []byte{prefix}, append(lenInBytes, data...)..., ), nil - case []interface{}: + case []any: encodedList, err := encodeRLPListItems(data) if err != nil { return nil, err @@ -92,87 +111,117 @@ func encodeRLP(val interface{}) ([]byte, error) { append(lenInBytes, encodedList...)..., ), nil default: - return nil, fmt.Errorf("input data should either be a list or a byte array") + return nil, errRLPEncodeInvalidType } } -func DecodeRLP(data []byte) (interface{}, error) { - res, consumed, err := decodeRLP(data) +func DecodeRLP(data []byte) (any, error) { + if len(data) == 0 { + return nil, errRLPEmptyInput + } + if len(data) > maxInputLen { + return nil, fmt.Errorf("invalid rlp data: input length %d exceeds limit of %d", len(data), maxInputLen) + } + res, consumed, err := decodeRLP(data, 0) if err != nil { return nil, err } if consumed != len(data) { - return nil, xerrors.Errorf("invalid rlp data: length %d, consumed %d", len(data), consumed) + return nil, fmt.Errorf("invalid rlp data: length %d, consumed %d", len(data), consumed) } return res, nil } -func decodeRLP(data []byte) (res interface{}, consumed int, err error) { +func decodeRLP(data []byte, depth int) (res any, consumed int, err error) { if len(data) == 0 { - return data, 0, xerrors.Errorf("invalid rlp data: data cannot be empty") + return nil, 0, errRLPEmptyInput } - if data[0] >= 0xf8 { - listLenInBytes := int(data[0]) - 0xf7 + + b := data[0] + + switch { + case b >= 0xf8: // long list (> 55 bytes content) + if depth > maxListDepth { + return nil, 0, fmt.Errorf("invalid rlp data: list nesting depth exceeds limit of %d", maxListDepth) + } + listLenInBytes := int(b) - 0xf7 listLen, err := decodeLength(data[1:], listLenInBytes) if err != nil { return nil, 0, err } + if listLen < 56 { + return nil, 0, errRLPNonCanonicalList + } if 1+listLenInBytes+listLen > len(data) { - return nil, 0, xerrors.Errorf("invalid rlp data: out of bound while parsing list") + return nil, 0, errRLPListBounds } - result, err := decodeListElems(data[1+listLenInBytes:], listLen) + result, err := decodeListElems(data[1+listLenInBytes:], listLen, depth+1) return result, 1 + listLenInBytes + listLen, err - } else if data[0] >= 0xc0 { - length := int(data[0]) - 0xc0 - result, err := decodeListElems(data[1:], length) + + case b >= 0xc0: // short list (0-55 bytes content) + if depth > maxListDepth { + return nil, 0, fmt.Errorf("invalid rlp data: list nesting depth exceeds limit of %d", maxListDepth) + } + length := int(b) - 0xc0 + if 1+length > len(data) { + return nil, 0, errRLPListBounds + } + result, err := decodeListElems(data[1:], length, depth+1) return result, 1 + length, err - } else if data[0] >= 0xb8 { - strLenInBytes := int(data[0]) - 0xb7 + + case b >= 0xb8: // long string (> 55 bytes) + strLenInBytes := int(b) - 0xb7 strLen, err := decodeLength(data[1:], strLenInBytes) if err != nil { return nil, 0, err } + if strLen < 56 { + return nil, 0, errRLPNonCanonicalString + } totalLen := 1 + strLenInBytes + strLen if totalLen > len(data) || totalLen < 0 { - return nil, 0, xerrors.Errorf("invalid rlp data: out of bound while parsing string") + return nil, 0, errRLPStringBounds } return data[1+strLenInBytes : totalLen], totalLen, nil - } else if data[0] >= 0x80 { - length := int(data[0]) - 0x80 + + case b >= 0x80: // short string (0-55 bytes) + length := int(b) - 0x80 if 1+length > len(data) { - return nil, 0, xerrors.Errorf("invalid rlp data: out of bound while parsing string") + return nil, 0, errRLPStringBounds + } + if length == 1 && data[1] < 0x80 { + return nil, 0, errRLPNonCanonicalByte } return data[1 : 1+length], 1 + length, nil + + default: // single byte [0x00, 0x7f] + return []byte{b}, 1, nil } - return []byte{data[0]}, 1, nil } -func decodeLength(data []byte, lenInBytes int) (length int, err error) { +// decodeLength reads a big-endian length from the first lenInBytes of data. +func decodeLength(data []byte, lenInBytes int) (int, error) { if lenInBytes > len(data) || lenInBytes > 8 { - return 0, xerrors.Errorf("invalid rlp data: out of bound while parsing list length") - } - var decodedLength int64 - r := bytes.NewReader(append(make([]byte, 8-lenInBytes), data[:lenInBytes]...)) - if err := binary.Read(r, binary.BigEndian, &decodedLength); err != nil { - return 0, xerrors.Errorf("invalid rlp data: cannot parse string length: %w", err) + return 0, errRLPLengthBounds } - if decodedLength < 0 { - return 0, xerrors.Errorf("invalid rlp data: negative string length") + if lenInBytes > 0 && data[0] == 0 { + return 0, errRLPNonCanonicalLeading } - - totalLength := lenInBytes + int(decodedLength) - if totalLength < 0 || totalLength > len(data) { - return 0, xerrors.Errorf("invalid rlp data: out of bound while parsing list") + var buf [8]byte + copy(buf[8-lenInBytes:], data[:lenInBytes]) + length := binary.BigEndian.Uint64(buf[:]) + if length > uint64(len(data)-lenInBytes) { + return 0, errRLPListBounds } - return int(decodedLength), nil + return int(length), nil } -func decodeListElems(data []byte, length int) (res []interface{}, err error) { +func decodeListElems(data []byte, length int, depth int) (res []any, err error) { totalConsumed := 0 - result := []interface{}{} + result := []any{} for i := 0; totalConsumed < length && i < maxListElements; i++ { - elem, consumed, err := decodeRLP(data[totalConsumed:]) + elem, consumed, err := decodeRLP(data[totalConsumed:], depth) if err != nil { return nil, xerrors.Errorf("invalid rlp data: cannot decode list element: %w", err) } @@ -180,7 +229,7 @@ func decodeListElems(data []byte, length int) (res []interface{}, err error) { result = append(result, elem) } if totalConsumed != length { - return nil, xerrors.Errorf("invalid rlp data: incorrect list length") + return nil, errRLPIncorrectListLen } return result, nil } diff --git a/chain/types/ethtypes/rlp_test.go b/chain/types/ethtypes/rlp_test.go index 0c74cf58c0a..7c85cbeccb0 100644 --- a/chain/types/ethtypes/rlp_test.go +++ b/chain/types/ethtypes/rlp_test.go @@ -1,6 +1,7 @@ package ethtypes import ( + "bytes" "encoding/hex" "fmt" "strings" @@ -11,6 +12,14 @@ import ( "github.com/filecoin-project/go-address" ) +func mustDecodeHex(s string) []byte { + d, err := hex.DecodeString(strings.Replace(s, "0x", "", -1)) + if err != nil { + panic(fmt.Errorf("err must be nil: %w", err)) + } + return d +} + func TestEncode(t *testing.T) { testcases := []TestCase{ {[]byte(""), mustDecodeHex("0x80")}, @@ -57,7 +66,7 @@ func TestEncode(t *testing.T) { for _, tc := range testcases { result, err := EncodeRLP(tc.Input) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, tc.Output.([]byte), result) } @@ -76,25 +85,17 @@ func TestDecodeString(t *testing.T) { for _, tc := range testcases { input, err := hex.DecodeString(strings.Replace(tc.Input.(string), "0x", "", -1)) - require.Nil(t, err) + require.NoError(t, err) output, err := hex.DecodeString(strings.Replace(tc.Output.(string), "0x", "", -1)) - require.Nil(t, err) + require.NoError(t, err) result, err := DecodeRLP(input) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, output, result.([]byte)) } } -func mustDecodeHex(s string) []byte { - d, err := hex.DecodeString(strings.Replace(s, "0x", "", -1)) - if err != nil { - panic(fmt.Errorf("err must be nil: %w", err)) - } - return d -} - func TestDecodeList(t *testing.T) { testcases := []TestCase{ {"0xc0", []interface{}{}}, @@ -128,12 +129,11 @@ func TestDecodeList(t *testing.T) { for _, tc := range testcases { input, err := hex.DecodeString(strings.Replace(tc.Input.(string), "0x", "", -1)) - require.Nil(t, err) + require.NoError(t, err) result, err := DecodeRLP(input) - require.Nil(t, err) + require.NoError(t, err) - fmt.Println(result) r := result.([]interface{}) require.Equal(t, len(tc.Output.([]interface{})), len(r)) @@ -167,10 +167,10 @@ func TestDecodeEncodeTx(t *testing.T) { for _, tc := range testcases { decoded, err := DecodeRLP(tc) - require.Nil(t, err) + require.NoError(t, err) encoded, err := EncodeRLP(decoded) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, tc, encoded) } } @@ -190,6 +190,185 @@ func TestDecodeError(t *testing.T) { } } +// TestDecodeLimits verifies the decoder's input validation bounds: max input +// size, list nesting depth, and per-list element count. +func TestDecodeLimits(t *testing.T) { + t.Run("empty input", func(t *testing.T) { + _, err := DecodeRLP([]byte{}) + require.ErrorContains(t, err, "data is empty") + }) + + t.Run("input too large", func(t *testing.T) { + // Just over the limit; content doesn't matter, the check is pre-parse. + data := make([]byte, maxInputLen+1) + data[0] = 0x80 + _, err := DecodeRLP(data) + require.ErrorContains(t, err, "exceeds limit") + }) + + t.Run("nesting exceeds limit", func(t *testing.T) { + payload := bytes.Repeat([]byte{0xc1}, maxListDepth+2) + _, err := DecodeRLP(payload) + require.ErrorContains(t, err, "nesting depth exceeds limit") + }) + + t.Run("nesting at limit with leaf value", func(t *testing.T) { + // Build a validly-encoded nested structure: maxListDepth layers of + // short lists wrapping a leaf byte. Each prefix encodes the correct + // content length: + // innermost: 0xc1 0x42 (list with 1-byte content) + // next out: 0xc2 0xc1 0x42 (list with 2-byte content) + // next out: 0xc3 0xc2 0xc1 0x42 (list with 3-byte content) + depth := maxListDepth + data := make([]byte, depth+1) + for i := 0; i < depth; i++ { + data[i] = byte(0xc0 + depth - i) + } + data[depth] = 0x42 + result, err := DecodeRLP(data) + require.NoError(t, err) + + // Unwrap the nested lists down to the leaf. + var cur interface{} = result + for i := 0; i < depth; i++ { + lst, ok := cur.([]interface{}) + require.True(t, ok, "expected list at depth %d", i) + require.Len(t, lst, 1) + cur = lst[0] + } + require.Equal(t, []byte{0x42}, cur) + }) +} + +// TestDecodeCanonical verifies that the decoder rejects non-canonical RLP +// encodings. Test vectors are derived from go-ethereum's rlp test suite +// (ErrCanonSize cases in decode_test.go and raw_test.go). +func TestDecodeCanonical(t *testing.T) { + // Single byte values [0x00..0x7f] must not use a string prefix. + t.Run("single byte with unnecessary prefix", func(t *testing.T) { + for _, tc := range []string{ + "8100", // 0x00 should be encoded as 0x00 + "8101", // 0x01 should be encoded as 0x01 + "8105", // 0x05 should be encoded as 0x05 + "817f", // 0x7f should be encoded as 0x7f + } { + _, err := DecodeRLP(mustDecodeHex("0x" + tc)) + require.ErrorContains(t, err, "non-canonical single byte", "input: %s", tc) + } + // 0x80 is the first value that legitimately needs the prefix. + _, err := DecodeRLP(mustDecodeHex("0x8180")) + require.NoError(t, err) + }) + + // Long string prefix (0xb8+) must not be used for lengths <= 55. + t.Run("long string prefix for short content", func(t *testing.T) { + for _, tc := range []string{ + "b800", // 0-byte string: should use 0x80 + "b80100", // 1-byte string: should use 0x81 + "b83700" + strings.Repeat("aa", 54), // 55-byte string: should use 0xb7 + } { + _, err := DecodeRLP(mustDecodeHex("0x" + tc)) + require.ErrorContains(t, err, "non-canonical", "input prefix: %s", tc[:4]) + } + }) + + // Long list prefix (0xf8+) must not be used for lengths <= 55. + t.Run("long list prefix for short content", func(t *testing.T) { + for _, tc := range []string{ + "f800", // 0-byte list: should use 0xc0 + "f80100", // 1-byte list: should use 0xc1 + } { + _, err := DecodeRLP(mustDecodeHex("0x" + tc)) + require.ErrorContains(t, err, "non-canonical", "input prefix: %s", tc[:4]) + } + }) + + // Length encoding must not have leading zero bytes. + t.Run("leading zeros in length", func(t *testing.T) { + for _, tc := range []string{ + "b90055" + strings.Repeat("aa", 0x55), // 2-byte len but 0x0055 has leading zero + "ba0002ffff", // 3-byte len but 0x0002ff has leading zero + } { + _, err := DecodeRLP(mustDecodeHex("0x" + tc)) + require.ErrorContains(t, err, "non-canonical", "input: %s...", tc[:6]) + } + }) +} + +// TestDecodeTruncated checks that the decoder rejects payloads where the +// header declares more content than is actually present. +func TestDecodeTruncated(t *testing.T) { + testcases := []struct { + name string + input string + }{ + {"short string truncated", "8201"}, // claims 2 bytes, only 1 + {"long string truncated", "b838" + strings.Repeat("ff", 10)}, // claims 56, only 10 + {"short list truncated", "c3000102ff"}, // list claims 3 bytes, but 4 follow so consumed != len (extra byte) + {"long list truncated", "f838" + strings.Repeat("00", 10)}, // claims 56 bytes content, only 10 + {"string longer than data", "850505050505ff"}, // claims 5 bytes, has 5 + trailing + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeRLP(mustDecodeHex("0x" + tc.input)) + require.Error(t, err, "input: %s", tc.input) + }) + } +} + +// TestDecodeEncodeRoundtrip verifies encode/decode symmetry across a range +// of value types including edge cases at encoding boundaries. +func TestDecodeEncodeRoundtrip(t *testing.T) { + testcases := []struct { + name string + val interface{} + }{ + {"empty string", []byte{}}, + {"single byte 0x00", []byte{0x00}}, + {"single byte 0x7f", []byte{0x7f}}, + {"single byte 0x80", []byte{0x80}}, + {"55 byte string", bytes.Repeat([]byte{0xab}, 55)}, + {"56 byte string", bytes.Repeat([]byte{0xab}, 56)}, + {"256 byte string", bytes.Repeat([]byte{0xab}, 256)}, + {"empty list", []interface{}{}}, + {"list with empty string", []interface{}{[]byte{}}}, + {"nested empty lists", []interface{}{[]interface{}{}}}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + encoded, err := EncodeRLP(tc.val) + require.NoError(t, err) + + decoded, err := DecodeRLP(encoded) + require.NoError(t, err) + + reencoded, err := EncodeRLP(decoded) + require.NoError(t, err) + require.Equal(t, encoded, reencoded) + }) + } +} + +// TestDecodeEncodeTxRoundtrip is the original transaction roundtrip test, +// exercising real transaction payloads including contract-deploy sizes. +func TestDecodeEncodeTxRoundtrip(t *testing.T) { + testcases := [][]byte{ + mustDecodeHex("0xdc82013a0185012a05f2008504a817c8008080872386f26fc1000000c0"), + mustDecodeHex("0xf85f82013a0185012a05f2008504a817c8008080872386f26fc1000000c001a027fa36fb9623e4d71fcdd7f7dce71eb814c9560dcf3908c1719386e2efd122fba05fb4e4227174eeb0ba84747a4fb883c8d4e0fdb129c4b1f42e90282c41480234"), + mustDecodeHex("0xf9061c82013a0185012a05f2008504a817c8008080872386f26fc10000b905bb608060405234801561001057600080fd5b506127106000803273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610556806100656000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80637bd703e81461004657806390b98a1114610076578063f8b2cb4f146100a6575b600080fd5b610060600480360381019061005b919061030a565b6100d6565b60405161006d9190610350565b60405180910390f35b610090600480360381019061008b9190610397565b6100f4565b60405161009d91906103f2565b60405180910390f35b6100c060048036038101906100bb919061030a565b61025f565b6040516100cd9190610350565b60405180910390f35b600060026100e38361025f565b6100ed919061043c565b9050919050565b6000816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410156101455760009050610259565b816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546101939190610496565b92505081905550816000808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546101e891906104ca565b925050819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161024c9190610350565b60405180910390a3600190505b92915050565b60008060008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020549050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006102d7826102ac565b9050919050565b6102e7816102cc565b81146102f257600080fd5b50565b600081359050610304816102de565b92915050565b6000602082840312156103205761031f6102a7565b5b600061032e848285016102f5565b91505092915050565b6000819050919050565b61034a81610337565b82525050565b60006020820190506103656000830184610341565b92915050565b61037481610337565b811461037f57600080fd5b50565b6000813590506103918161036b565b92915050565b600080604083850312156103ae576103ad6102a7565b5b60006103bc858286016102f5565b92505060206103cd85828601610382565b9150509250929050565b60008115159050919050565b6103ec816103d7565b82525050565b600060208201905061040760008301846103e3565b92915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b600061044782610337565b915061045283610337565b9250817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048311821515161561048b5761048a61040d565b5b828202905092915050565b60006104a182610337565b91506104ac83610337565b9250828210156104bf576104be61040d565b5b828203905092915050565b60006104d582610337565b91506104e083610337565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff038211156105155761051461040d565b5b82820190509291505056fea26469706673582212208e5b4b874c839967f88008ed2fa42d6c2d9c9b0ae05d1d2c61faa7d229c134e664736f6c634300080d0033c080a0c4e9477f57c6848b2f1ea73a14809c1f44529d20763c947f3ac8ffd3d1629d93a011485a215457579bb13ac7b53bb9d6804763ae6fe5ce8ddd41642cea55c9a09a"), + mustDecodeHex("0xf9063082013a0185012a05f2008504a817c8008094025b594a4f1c4888cafcfaf2bb24ed95507749e0872386f26fc10000b905bb608060405234801561001057600080fd5b506127106000803273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610556806100656000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80637bd703e81461004657806390b98a1114610076578063f8b2cb4f146100a6575b600080fd5b610060600480360381019061005b919061030a565b6100d6565b60405161006d9190610350565b60405180910390f35b610090600480360381019061008b9190610397565b6100f4565b60405161009d91906103f2565b60405180910390f35b6100c060048036038101906100bb919061030a565b61025f565b6040516100cd9190610350565b60405180910390f35b600060026100e38361025f565b6100ed919061043c565b9050919050565b6000816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410156101455760009050610259565b816000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546101939190610496565b92505081905550816000808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546101e891906104ca565b925050819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161024c9190610350565b60405180910390a3600190505b92915050565b60008060008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020549050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006102d7826102ac565b9050919050565b6102e7816102cc565b81146102f257600080fd5b50565b600081359050610304816102de565b92915050565b6000602082840312156103205761031f6102a7565b5b600061032e848285016102f5565b91505092915050565b6000819050919050565b61034a81610337565b82525050565b60006020820190506103656000830184610341565b92915050565b61037481610337565b811461037f57600080fd5b50565b6000813590506103918161036b565b92915050565b600080604083850312156103ae576103ad6102a7565b5b60006103bc858286016102f5565b92505060206103cd85828601610382565b9150509250929050565b60008115159050919050565b6103ec816103d7565b82525050565b600060208201905061040760008301846103e3565b92915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b600061044782610337565b915061045283610337565b9250817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048311821515161561048b5761048a61040d565b5b828202905092915050565b60006104a182610337565b91506104ac83610337565b9250828210156104bf576104be61040d565b5b828203905092915050565b60006104d582610337565b91506104e083610337565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff038211156105155761051461040d565b5b82820190509291505056fea26469706673582212208e5b4b874c839967f88008ed2fa42d6c2d9c9b0ae05d1d2c61faa7d229c134e664736f6c634300080d0033c080a0fe38720928596f9e9dfbf891d00311638efce3713f03cdd67b212ecbbcf18f29a05993e656c0b35b8a580da6aff7c89b3d3e8b1c6f83a7ce09473c0699a8500b9c"), + } + + for _, tc := range testcases { + decoded, err := DecodeRLP(tc) + require.NoError(t, err) + + encoded, err := EncodeRLP(decoded) + require.NoError(t, err) + require.Equal(t, tc, encoded) + } +} + func TestDecode1(t *testing.T) { b := mustDecodeHex("0x02f8758401df5e7680832c8411832c8411830767f89452963ef50e27e06d72d59fcb4f3c2a687be3cfef880de0b6b3a764000080c080a094b11866f453ad85a980e0e8a2fc98cbaeb4409618c7734a7e12ae2f66fd405da042dbfb1b37af102023830ceeee0e703ffba0b8b3afeb8fe59f405eca9ed61072") decoded, err := parseEip1559Tx(b) From bc12aa92701f8a76694166b74389d2398f8be049 Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 26 Mar 2026 18:15:51 +1100 Subject: [PATCH 2/3] fixup! fix: harden RLP decoder with input validation and canonical encoding checks --- chain/types/ethtypes/rlp.go | 2 +- chain/types/ethtypes/rlp_test.go | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/chain/types/ethtypes/rlp.go b/chain/types/ethtypes/rlp.go index 87349f25c7d..9f0769091df 100644 --- a/chain/types/ethtypes/rlp.go +++ b/chain/types/ethtypes/rlp.go @@ -211,7 +211,7 @@ func decodeLength(data []byte, lenInBytes int) (int, error) { copy(buf[8-lenInBytes:], data[:lenInBytes]) length := binary.BigEndian.Uint64(buf[:]) if length > uint64(len(data)-lenInBytes) { - return 0, errRLPListBounds + return 0, errRLPLengthBounds } return int(length), nil } diff --git a/chain/types/ethtypes/rlp_test.go b/chain/types/ethtypes/rlp_test.go index 7c85cbeccb0..7bb9d9b185c 100644 --- a/chain/types/ethtypes/rlp_test.go +++ b/chain/types/ethtypes/rlp_test.go @@ -190,8 +190,8 @@ func TestDecodeError(t *testing.T) { } } -// TestDecodeLimits verifies the decoder's input validation bounds: max input -// size, list nesting depth, and per-list element count. +// TestDecodeLimits verifies the decoder's input validation bounds: empty input, +// max input size, list nesting depth, and per-list element count. func TestDecodeLimits(t *testing.T) { t.Run("empty input", func(t *testing.T) { _, err := DecodeRLP([]byte{}) @@ -238,6 +238,18 @@ func TestDecodeLimits(t *testing.T) { } require.Equal(t, []byte{0x42}, cur) }) + + t.Run("list element count exceeds limit", func(t *testing.T) { + // Encode a list with maxListElements+1 single-byte elements. + elems := make([]byte, maxListElements+1) + for i := range elems { + elems[i] = byte(i) + } + // Short list prefix: 0xc0 + content length. + data := append([]byte{byte(0xc0 + len(elems))}, elems...) + _, err := DecodeRLP(data) + require.ErrorContains(t, err, "incorrect list length") + }) } // TestDecodeCanonical verifies that the decoder rejects non-canonical RLP @@ -295,18 +307,19 @@ func TestDecodeCanonical(t *testing.T) { }) } -// TestDecodeTruncated checks that the decoder rejects payloads where the -// header declares more content than is actually present. -func TestDecodeTruncated(t *testing.T) { +// TestDecodeLengthMismatch checks that the decoder rejects payloads where the +// declared length does not match the available data (truncated payloads or +// extra trailing bytes). +func TestDecodeLengthMismatch(t *testing.T) { testcases := []struct { name string input string }{ {"short string truncated", "8201"}, // claims 2 bytes, only 1 {"long string truncated", "b838" + strings.Repeat("ff", 10)}, // claims 56, only 10 - {"short list truncated", "c3000102ff"}, // list claims 3 bytes, but 4 follow so consumed != len (extra byte) + {"short list trailing data", "c3000102ff"}, // list claims 3 bytes content, but 4 follow (extra byte) {"long list truncated", "f838" + strings.Repeat("00", 10)}, // claims 56 bytes content, only 10 - {"string longer than data", "850505050505ff"}, // claims 5 bytes, has 5 + trailing + {"string with trailing data", "850505050505ff"}, // claims 5 bytes, has 5 + trailing } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { From 87b28a16565e578753d643db0240ba01842064f9 Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Fri, 27 Mar 2026 19:20:03 +1100 Subject: [PATCH 3/3] fixup! fix: harden RLP decoder with input validation and canonical encoding checks --- chain/types/ethtypes/rlp.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/chain/types/ethtypes/rlp.go b/chain/types/ethtypes/rlp.go index 9f0769091df..301165a548d 100644 --- a/chain/types/ethtypes/rlp.go +++ b/chain/types/ethtypes/rlp.go @@ -14,10 +14,10 @@ const ( // at 12 (legacy has 9). This is set to match that upper bound. maxListElements = 12 - // maxListDepth limits how deeply RLP lists may be nested. Ethereum - // transactions nest at most 3 levels deep (tx list > access list > - // [address, storageKeys] tuple > keys list). 32 provides generous - // headroom for any future transaction format. + // maxListDepth limits how deeply RLP lists may be nested. Input length + // alone does not bound recursion depth, a sequence of 0xc1 bytes + // creates one stack frame per byte, so an explicit limit is required. + // Ethereum transactions nest at most 3 levels deep; 32 is generous. maxListDepth = 32 // maxInputLen is the largest RLP blob we will attempt to decode. @@ -136,14 +136,14 @@ func decodeRLP(data []byte, depth int) (res any, consumed int, err error) { if len(data) == 0 { return nil, 0, errRLPEmptyInput } + if depth > maxListDepth { + return nil, 0, fmt.Errorf("invalid rlp data: list nesting depth exceeds limit of %d", maxListDepth) + } b := data[0] switch { case b >= 0xf8: // long list (> 55 bytes content) - if depth > maxListDepth { - return nil, 0, fmt.Errorf("invalid rlp data: list nesting depth exceeds limit of %d", maxListDepth) - } listLenInBytes := int(b) - 0xf7 listLen, err := decodeLength(data[1:], listLenInBytes) if err != nil { @@ -159,9 +159,6 @@ func decodeRLP(data []byte, depth int) (res any, consumed int, err error) { return result, 1 + listLenInBytes + listLen, err case b >= 0xc0: // short list (0-55 bytes content) - if depth > maxListDepth { - return nil, 0, fmt.Errorf("invalid rlp data: list nesting depth exceeds limit of %d", maxListDepth) - } length := int(b) - 0xc0 if 1+length > len(data) { return nil, 0, errRLPListBounds