Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 95 additions & 32 deletions openfeature/flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move capped flattening off the exposure hot path

maxFlattenFields does not only guard the background EVP aggregation path: flattenRecursive is also used by flattenContext, which exposureHook.After calls 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 with doLog enabled 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 👍 / 👎.

)

// 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.
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Distinguish overlapping slices from cycles

Using only reflect.ValueOf(v).Pointer() as the slice identity makes any subslice that starts at the same backing-array element look cyclic. For example, with s := []any{"leaf", nil}; t := s[:1}; s[1] = t, the new check skips s.1 even though flattening it terminates and previously produced s.1.0. This drops valid context fields when callers reuse prefix subslices; include length/capacity in the stack key or otherwise detect exact slice cycles.

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:
Expand All @@ -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)
}
}

Expand Down
146 changes: 146 additions & 0 deletions openfeature/flatten_test.go
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()))
})
}
}
Loading