From 672d5d22a347a137e1f66654f0d28749309f40f4 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Fri, 2 Oct 2026 14:44:41 +0000 Subject: [PATCH] fix: prevent stack overflow on circular key references during value expansion When expanding variable references (%(name)s), circular references between keys (such as a = %(b)s and b = %(a)s, or self-references in the default section) lead to infinite recursion during Key.String() evaluation, resulting in a fatal goroutine stack overflow panic. Track visited keys along the expansion path and enforce the recursion depth limit depthValues during recursive substitution. When a circular reference or unresolvable key is encountered, stop recursion cleanly and return the current string without crashing. Add regression tests covering mutual circular references, default section self-references, and multi-key cycles. Fixes #409 --- key.go | 27 ++++++++++++++++++++++++++- key_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/key.go b/key.go index 800d04a..b136607 100644 --- a/key.go +++ b/key.go @@ -138,8 +138,25 @@ func (k *Key) NestedValues() []string { return k.nestedValues } +func hasVisitedKey(visited []*Key, k *Key) bool { + for _, v := range visited { + if v == k { + return true + } + } + return false +} + // transformValue takes a raw value and transforms to its final string. func (k *Key) transformValue(val string) string { + return k.transformValueWithVisited(val, []*Key{k}) +} + +func (k *Key) transformValueWithVisited(val string, visited []*Key) string { + if len(visited) > depthValues { + return val + } + if k.s.f.ValueMapper != nil { val = k.s.f.ValueMapper(val) } @@ -169,8 +186,16 @@ func (k *Key) transformValue(val string) string { } } + if hasVisitedKey(visited, nk) { + break + } + // Substitute by new value and take off leading '%(' and trailing ')s'. - val = strings.ReplaceAll(val, vr, nk.String()) + res := nk.transformValueWithVisited(nk.value, append(visited, nk)) + val = strings.ReplaceAll(val, vr, res) + if strings.Contains(res, vr) { + break + } } return val } diff --git a/key_test.go b/key_test.go index e57a7bd..48ddbd9 100644 --- a/key_test.go +++ b/key_test.go @@ -654,4 +654,42 @@ ENDPOINT = https://%(ACCOUNT_ID)s.r2.cloudflarestorage.com assert.Equal(t, "https://abc123.r2.cloudflarestorage.com", f.Section("").Key("ENDPOINT").String()) }) + + t.Run("circular reference between keys should not stack overflow", func(t *testing.T) { + f, err := Load([]byte(` +[s] +a = %(b)s +b = %(a)s +`)) + require.NoError(t, err) + require.NotNil(t, f) + + assert.Equal(t, "%(a)s", f.Section("s").Key("a").String()) + assert.Equal(t, "%(b)s", f.Section("s").Key("b").String()) + }) + + t.Run("self-reference in default section should not stack overflow", func(t *testing.T) { + f, err := Load([]byte(` +a = %(a)s +`)) + require.NoError(t, err) + require.NotNil(t, f) + + assert.Equal(t, "%(a)s", f.Section("").Key("a").String()) + }) + + t.Run("three-way circular reference should not stack overflow", func(t *testing.T) { + f, err := Load([]byte(` +[s] +a = %(b)s +b = %(c)s +c = %(a)s +`)) + require.NoError(t, err) + require.NotNil(t, f) + + assert.Equal(t, "%(a)s", f.Section("s").Key("a").String()) + assert.Equal(t, "%(b)s", f.Section("s").Key("b").String()) + assert.Equal(t, "%(c)s", f.Section("s").Key("c").String()) + }) }