diff --git a/openfeature/flatten.go b/openfeature/flatten.go index 84d89043c25..7594161852a 100644 --- a/openfeature/flatten.go +++ b/openfeature/flatten.go @@ -7,12 +7,37 @@ package openfeature import ( "fmt" + "reflect" "strconv" "strings" "github.com/DataDog/dd-trace-go/v2/internal/log" ) +const ( + // maxContextDepth bounds how deep context flattening will recurse. + // + // The evaluation context is attacker-influenceable: a deeply nested map[string]any / []any + // would otherwise recurse without limit. In Go a stack overflow is a fatal runtime error that + // recover() cannot catch, so an unbounded traversal is a process-crash DoS. 32 is far deeper + // than any real evaluation context and matches the other SDKs. + maxContextDepth = 32 + + // maxFlattenFields is a safety ceiling on the TOTAL number of fields a single flatten produces. + // + // Depth capping and cycle detection stop unbounded depth and true cycles, but a context that + // shares the same child map/slice across many keys (a DAG) still fans out multiplicatively — + // e.g. branching-2 nesting expands ~2^depth leaves before any downstream cap applies. This + // bounds that memory/CPU amplification. + // + // It sits far above maxContextFields (the 256-field intake prune), so it never affects a real + // context: legitimate inputs flatten well below it and the deterministic 256-field prune that + // builds the aggregation bucket key is unchanged; only pathological amplification is truncated. + // Flattening runs on the background aggregation worker, not the evaluation hot path, so this + // adds no per-evaluation cost. + maxFlattenFields = 1 << 16 // 65536 +) + // flattenRecursive recursively flattens nested attributes into a single-level map // using dot notation for nested keys. This ensures that subject attributes in exposure // events comply with the EVP intake schema which does not allow nested objects. @@ -26,62 +51,100 @@ import ( // {"user.id": "123", "user.email": "test@example.com"} // // The flattening is applied during both flag evaluation and exposure event creation. +// Traversal is bounded so an attacker-supplied context cannot exhaust the stack or memory: +// recursion stops at maxContextDepth, cycles through map[string]any / []any containers are +// detected and skipped, and the total field count is capped at maxFlattenFields. func flattenRecursive(prefix string, value any, result map[string]any) { + flattenRecursiveDepth(prefix, value, result, nil, 0) +} + +func flattenRecursiveDepth(prefix string, value any, result map[string]any, seen map[uintptr]struct{}, depth int) { + if len(result) >= maxFlattenFields { + return + } + if depth > maxContextDepth { + log.Debug("openfeature: skipping attribute %q: context nesting exceeds max depth %d", prefix, maxContextDepth) + return + } + switch v := value.(type) { case map[string]any: - flattenRecursiveMap(prefix, v, result) + // map[string]any values are `any`, so this container can reference itself (a cycle) or be + // shared. Track its identity on the recursion stack and skip if we are already inside it. + ptr := reflect.ValueOf(v).Pointer() + if _, cyclic := seen[ptr]; cyclic { + log.Debug("openfeature: skipping attribute %q: cyclic evaluation context reference", prefix) + return + } + if seen == nil { + seen = make(map[uintptr]struct{}, 1) + } + seen[ptr] = struct{}{} + flattenRecursiveMap(prefix, v, result, seen, depth) + delete(seen, ptr) case map[string]string: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]uint: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]int: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]int64: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]int32: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]uint64: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]uint32: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]int16: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]int8: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]uint16: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]uint8: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]float64: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]bool: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) case map[string]float32: - flattenRecursiveMap(prefix, v, result) + flattenRecursiveMap(prefix, v, result, seen, depth) + case []any: + // []any elements are `any` and can likewise reference the slice itself or be shared. + ptr := reflect.ValueOf(v).Pointer() + if _, cyclic := seen[ptr]; cyclic { + log.Debug("openfeature: skipping attribute %q: cyclic evaluation context reference", prefix) + return + } + if seen == nil { + seen = make(map[uintptr]struct{}, 1) + } + seen[ptr] = struct{}{} + flattenRecursiveArray(prefix, v, result, seen, depth) + delete(seen, ptr) case []string: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []int: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []int64: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []int32: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []uint64: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []uint32: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []int16: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []uint16: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []float64: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []bool: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []float32: - flattenRecursiveArray(prefix, v, result) - case []any: - flattenRecursiveArray(prefix, v, result) + flattenRecursiveArray(prefix, v, result, seen, depth) case []byte: result[prefix] = string(v) case fmt.Stringer: @@ -93,20 +156,20 @@ func flattenRecursive(prefix string, value any, result map[string]any) { } } -func flattenRecursiveMap[T any](prefix string, v map[string]T, result map[string]any) { +func flattenRecursiveMap[T any](prefix string, v map[string]T, result map[string]any, seen map[uintptr]struct{}, depth int) { for key, val := range v { newPrefix := key if prefix != "" { newPrefix = prefix + "." + key } - flattenRecursive(newPrefix, val, result) + flattenRecursiveDepth(newPrefix, val, result, seen, depth+1) } } -func flattenRecursiveArray[T any](prefix string, v []T, result map[string]any) { +func flattenRecursiveArray[T any](prefix string, v []T, result map[string]any, seen map[uintptr]struct{}, depth int) { for i, item := range v { flattenKey := prefix + "." + strconv.Itoa(i) - flattenRecursive(flattenKey, item, result) + flattenRecursiveDepth(flattenKey, item, result, seen, depth+1) } } diff --git a/openfeature/flatten_test.go b/openfeature/flatten_test.go new file mode 100644 index 00000000000..bc97f350c53 --- /dev/null +++ b/openfeature/flatten_test.go @@ -0,0 +1,146 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package openfeature + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFlattenContext exercises both the normal flattening behavior and the traversal bounds +// (depth cap, cycle detection, and the total-field safety ceiling) that keep an +// attacker-influenceable evaluation context from exhausting the stack or memory. +// +// Cyclic / self-referential / deeply nested contexts cannot be expressed as map literals, so each +// case builds its input via a closure and verifies the result via a closure. Exact-output cases +// assert equality; the safety-bound cases assert the result is bounded (and, critically, that the +// call returns at all — an unbounded traversal would fatally overflow the Go stack or OOM the +// process rather than fail an assertion). +func TestFlattenContext(t *testing.T) { + tests := []struct { + name string + build func() map[string]any + verify func(t *testing.T, got map[string]any) + }{ + { + name: "nested maps flatten to dot notation", + build: func() map[string]any { + return map[string]any{ + "user": map[string]any{"id": "123", "email": "a@b.com"}, + "plan": "pro", + } + }, + verify: func(t *testing.T, got map[string]any) { + assert.Equal(t, map[string]any{ + "user.id": "123", + "user.email": "a@b.com", + "plan": "pro", + }, got) + }, + }, + { + name: "nested arrays flatten with indices", + build: func() map[string]any { + return map[string]any{"tags": []any{"a", "b"}} + }, + verify: func(t *testing.T, got map[string]any) { + assert.Equal(t, map[string]any{"tags.0": "a", "tags.1": "b"}, got) + }, + }, + { + name: "direct self-reference is skipped, siblings kept", + build: func() map[string]any { + m := map[string]any{"name": "leo"} + m["self"] = m // cycle: m -> m + return m + }, + verify: func(t *testing.T, got map[string]any) { + // The cyclic branch is dropped entirely; the scalar sibling survives. + assert.Equal(t, map[string]any{"name": "leo"}, got) + }, + }, + { + name: "indirect cycle a -> b -> a is broken", + build: func() map[string]any { + a := map[string]any{"leaf": "x"} + b := map[string]any{"a": a} + a["b"] = b // a -> b -> a + return map[string]any{"root": a} + }, + verify: func(t *testing.T, got map[string]any) { + assert.Equal(t, map[string]any{"root.leaf": "x"}, got) + }, + }, + { + name: "shared subtree (diamond) is not treated as a cycle", + build: func() map[string]any { + shared := map[string]any{"v": "1"} + return map[string]any{"x": shared, "y": shared} + }, + verify: func(t *testing.T, got map[string]any) { + // Both sibling branches must fully flatten — cycle detection only breaks + // references that are live on the current recursion stack. + assert.Equal(t, map[string]any{"x.v": "1", "y.v": "1"}, got) + }, + }, + { + name: "map nesting beyond the depth cap is dropped", + build: func() map[string]any { + deep := map[string]any{"leaf": "deep-value"} + for range maxContextDepth + 10 { + deep = map[string]any{"n": deep} + } + return deep + }, + verify: func(t *testing.T, got map[string]any) { + for k, v := range got { + assert.NotEqual(t, "deep-value", v, "leaf below the depth cap must be dropped") + assert.LessOrEqual(t, strings.Count(k, "."), maxContextDepth) + } + }, + }, + { + name: "array nesting beyond the depth cap is dropped", + build: func() map[string]any { + var deep any = "leaf-value" + for range maxContextDepth + 10 { + deep = []any{deep} + } + return map[string]any{"arr": deep} + }, + verify: func(t *testing.T, got map[string]any) { + for _, v := range got { + assert.NotEqual(t, "leaf-value", v, "leaf below the depth cap must be dropped") + } + }, + }, + { + name: "shared-subtree fan-out is bounded by the field ceiling", + build: func() map[string]any { + // Branching-2 nesting reusing the same child at each level would expand to ~2^20 + // leaves without the ceiling; depth (20) stays under maxContextDepth so the depth + // cap alone does not save us here — only maxFlattenFields does. + var cur any = map[string]any{"x": "v"} + for range 20 { + cur = map[string]any{"a": cur, "b": cur} + } + return map[string]any{"root": cur} + }, + verify: func(t *testing.T, got map[string]any) { + assert.NotEmpty(t, got) + assert.LessOrEqual(t, len(got), maxFlattenFields) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.verify(t, flattenContext(tt.build())) + }) + } +}