-
Notifications
You must be signed in to change notification settings - Fork 536
fix(openfeature): bound context flattening depth and detect cycles #4973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4e4a35d
74c3a7f
b799b15
244502d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Comment on lines
+115
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using only Useful? React with 👍 / 👎. |
||
| 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) | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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())) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maxFlattenFieldsdoes not only guard the background EVP aggregation path:flattenRecursiveis also used byflattenContext, whichexposureHook.Aftercalls before appending the exposure event (openfeature/exposure_hook.go:72). The OpenFeature SDK invokes After hooks synchronously before returning evaluation details, so for successful evaluations withdoLogenabled and an allocation key, a pathological context can still make the request flatten up to 65,536 entries. That violates the intended off-hot-path guarantee; move exposure flattening to the writer or use a much smaller synchronous snapshot/cap.Useful? React with 👍 / 👎.