Skip to content

Commit b9c76cc

Browse files
patrislavclaude
andauthored
fix: add cycle detection to EIP-712 typed data encoding to prevent stack overflow DoS (#210)
Cyclic type definitions (e.g. A→B→A) caused infinite recursion in EncodeType/encodeValue, leading to an unrecoverable stack overflow. This was reachable via unauthenticated endpoints with a ~200-byte payload. Add ValidateTypeGraph() with DFS cycle detection, called in both UnmarshalJSON and Encode to reject cycles before any recursion begins. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a84595 commit b9c76cc

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

ethcoder/typed_data.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,38 @@ type TypedData struct {
2222

2323
type TypedDataTypes map[string][]TypedDataArgument
2424

25+
// ValidateTypeGraph checks the type graph for cycles. A cycle would cause
26+
// infinite recursion in EncodeType/encodeValue, leading to an unrecoverable
27+
// stack overflow. This must be called before any recursive type traversal.
28+
func (t TypedDataTypes) ValidateTypeGraph() error {
29+
for typeName := range t {
30+
if err := t.walkTypeGraph(typeName, make(map[string]bool)); err != nil {
31+
return err
32+
}
33+
}
34+
return nil
35+
}
36+
37+
func (t TypedDataTypes) walkTypeGraph(current string, visiting map[string]bool) error {
38+
if visiting[current] {
39+
return fmt.Errorf("cycle detected in type graph at %q", current)
40+
}
41+
visiting[current] = true
42+
defer delete(visiting, current)
43+
for _, field := range t[current] {
44+
baseType := field.Type
45+
if i := strings.Index(baseType, "["); i > 0 {
46+
baseType = baseType[:i]
47+
}
48+
if _, ok := t[baseType]; ok {
49+
if err := t.walkTypeGraph(baseType, visiting); err != nil {
50+
return err
51+
}
52+
}
53+
}
54+
return nil
55+
}
56+
2557
func (t TypedDataTypes) EncodeType(primaryType string) (string, error) {
2658
args, ok := t[primaryType]
2759
if !ok {
@@ -231,6 +263,10 @@ func (t *TypedData) encodeValue(typ string, value interface{}) ([]byte, error) {
231263
// * the digest is the hash of the fully encoded EIP712 message
232264
// * the encoded message is the fully encoded EIP712 message (0x1901 + domain + hashStruct(message))
233265
func (t *TypedData) Encode() ([]byte, []byte, error) {
266+
if err := t.Types.ValidateTypeGraph(); err != nil {
267+
return nil, nil, err
268+
}
269+
234270
EIP191_HEADER := "0x1901" // EIP191 for typed data
235271
eip191Header, err := HexDecode(EIP191_HEADER)
236272
if err != nil {

ethcoder/typed_data_json.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ func (t *TypedData) UnmarshalJSON(data []byte) error {
202202
domain.ChainID = chainID
203203
}
204204

205+
// Validate the type graph for cycles before any recursive traversal
206+
if err := raw.Types.ValidateTypeGraph(); err != nil {
207+
return err
208+
}
209+
205210
// Decode the raw message into Go runtime types
206211
message, err := typedDataDecodeRawMessageMap(raw.Types.Map(), raw.PrimaryType, raw.Message)
207212
if err != nil {

ethcoder/typed_data_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ethcoder_test
33
import (
44
"encoding/json"
55
"math/big"
6+
"strings"
67
"testing"
78

89
"github.com/0xsequence/ethkit/ethcoder"
@@ -731,3 +732,109 @@ func TestTypedDataFromJSONPart6(t *testing.T) {
731732
require.NoError(t, err)
732733
require.Equal(t, digest, digest2)
733734
}
735+
736+
func TestTypedDataCycleDetection(t *testing.T) {
737+
t.Run("simple cycle A->B->A", func(t *testing.T) {
738+
types := ethcoder.TypedDataTypes{
739+
"EIP712Domain": {},
740+
"A": {{Name: "b", Type: "B"}},
741+
"B": {{Name: "a", Type: "A"}},
742+
}
743+
err := types.ValidateTypeGraph()
744+
require.Error(t, err)
745+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
746+
})
747+
748+
t.Run("self-referencing type A->A", func(t *testing.T) {
749+
types := ethcoder.TypedDataTypes{
750+
"EIP712Domain": {},
751+
"A": {{Name: "self", Type: "A"}},
752+
}
753+
err := types.ValidateTypeGraph()
754+
require.Error(t, err)
755+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
756+
})
757+
758+
t.Run("longer cycle A->B->C->A", func(t *testing.T) {
759+
types := ethcoder.TypedDataTypes{
760+
"EIP712Domain": {},
761+
"A": {{Name: "b", Type: "B"}},
762+
"B": {{Name: "c", Type: "C"}},
763+
"C": {{Name: "a", Type: "A"}},
764+
}
765+
err := types.ValidateTypeGraph()
766+
require.Error(t, err)
767+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
768+
})
769+
770+
t.Run("cycle through array type A->B[]->A", func(t *testing.T) {
771+
types := ethcoder.TypedDataTypes{
772+
"EIP712Domain": {},
773+
"A": {{Name: "bs", Type: "B[]"}},
774+
"B": {{Name: "a", Type: "A"}},
775+
}
776+
err := types.ValidateTypeGraph()
777+
require.Error(t, err)
778+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
779+
})
780+
781+
t.Run("valid DAG with diamond shape", func(t *testing.T) {
782+
types := ethcoder.TypedDataTypes{
783+
"EIP712Domain": {},
784+
"A": {{Name: "b", Type: "B"}, {Name: "c", Type: "C"}},
785+
"B": {{Name: "d", Type: "D"}},
786+
"C": {{Name: "d", Type: "D"}},
787+
"D": {{Name: "value", Type: "uint256"}},
788+
}
789+
err := types.ValidateTypeGraph()
790+
require.NoError(t, err)
791+
})
792+
793+
t.Run("valid simple types no cycle", func(t *testing.T) {
794+
types := ethcoder.TypedDataTypes{
795+
"EIP712Domain": {},
796+
"Person": {
797+
{Name: "name", Type: "string"},
798+
{Name: "wallet", Type: "address"},
799+
},
800+
}
801+
err := types.ValidateTypeGraph()
802+
require.NoError(t, err)
803+
})
804+
805+
t.Run("cycle rejected during JSON unmarshal", func(t *testing.T) {
806+
typedDataJson := `{
807+
"types": {
808+
"EIP712Domain": [],
809+
"A": [{"name": "b", "type": "B"}],
810+
"B": [{"name": "a", "type": "A"}]
811+
},
812+
"primaryType": "A",
813+
"domain": {},
814+
"message": {"b": {"a": {}}}
815+
}`
816+
_, err := ethcoder.TypedDataFromJSON(typedDataJson)
817+
require.Error(t, err)
818+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
819+
})
820+
821+
t.Run("cycle rejected during Encode", func(t *testing.T) {
822+
typedData := &ethcoder.TypedData{
823+
Types: ethcoder.TypedDataTypes{
824+
"EIP712Domain": {},
825+
"A": {{Name: "b", Type: "B"}},
826+
"B": {{Name: "a", Type: "A"}},
827+
},
828+
PrimaryType: "A",
829+
Domain: ethcoder.TypedDataDomain{},
830+
Message: map[string]interface{}{"b": map[string]interface{}{"a": map[string]interface{}{}}},
831+
}
832+
_, _, err := typedData.Encode()
833+
require.Error(t, err)
834+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
835+
836+
_, err = typedData.EncodeDigest()
837+
require.Error(t, err)
838+
assert.True(t, strings.Contains(err.Error(), "cycle detected"))
839+
})
840+
}

0 commit comments

Comments
 (0)