Skip to content

fix(openfeature): bound context flattening depth and detect cycles#4973

Open
leoromanovsky wants to merge 4 commits into
mainfrom
leo.romanovsky/ffl-2651-openfeature-context-flatten-guard
Open

fix(openfeature): bound context flattening depth and detect cycles#4973
leoromanovsky wants to merge 4 commits into
mainfrom
leo.romanovsky/ffl-2651-openfeature-context-flatten-guard

Conversation

@leoromanovsky

@leoromanovsky leoromanovsky commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Bounds the recursion in OpenFeature evaluation-context flattening (openfeature/flatten.go) with the three guards the security finding called for:

  • Depth cap (maxContextDepth = 32) — stops unbounded nesting.
  • Cycle detection — tracks map[string]any / []any identity on the recursion stack and skips references already in progress. Breaks true cycles; shared sub-trees (diamonds) still flatten fully since identity is removed on the way out.
  • Total-field ceiling (maxFlattenFields = 65536) — bounds fan-out from shared-child (DAG) contexts, which neither depth nor cycle detection stops on its own.

This hardens both the EVP flagevaluation path (flattenAndPruneContext) and the older exposure path (flattenContext), which both funnel through flattenRecursive.

Motivation

A Codex security scan (finding d4c4823ac5148191bd9869bb77f0227e, tracked as APMSP-3616 / FFL-2651) flagged unbounded evaluation-context flattening with "no depth limit, cycle detection, or early field cap." The evaluation context is attacker-influenceable:

  • A deeply nested or self-referential map[string]any recursed without limit. In Go a stack overflow is a fatal, unrecoverable runtime errorrecover() cannot catch it — so this is a process-crash DoS.
  • A context sharing a child map/slice across many keys fans out ~2^depth flattened fields, a memory/CPU amplification, before the downstream 256-field prune runs.

dd-trace-rb fixed the same defect class in DataDog/dd-trace-rb#5896. Sibling follow-ups are tracked for dd-trace-js and dd-trace-py (Java/.NET prune flat only and are not exposed).

Decisions

  • All three guards, not just depth. Depth alone stops the crash but not the amplification; the field ceiling bounds DAG fan-out and cycle detection keeps self-referential contexts clean.
  • The field ceiling is a safety net, not a semantic limit. At 65536 it sits far above the 256-field intake prune, so legitimate contexts never reach it and the deterministic 256-field prune that builds the aggregation bucket key is unchanged — only pathological amplification is truncated. (A low, nondeterministic early cut would corrupt bucket identity and split counts.)
  • No hot-path cost. Flattening runs on the background aggregation worker (aggregate() on a single goroutine), never on the evaluation Finally hook, so none of this touches per-evaluation latency. Benchmarks: 0 regressions across 324 metrics.
  • flattenRecursive's public signature is unchanged — conflict-free with fix(openfeature): bound flagevaluation EVP payloads #4934 and safe for both call sites.

Testing

openfeature/flatten_test.go is table-driven: normal map/array flattening, direct self-reference, indirect a→b→a cycle, diamond (not a cycle), depth-bounded map and array, and shared-subtree fan-out bounded by the field ceiling. Full ./openfeature/ suite passes; go vet and golangci-lint clean.

flattenRecursive walked map[string]any / []any evaluation context with no depth
limit and no cycle detection. An attacker-influenced context with deep nesting
or a self-referential map could recurse until the Go runtime hit a fatal,
unrecoverable stack overflow (a process-crash DoS, worse than a degraded
telemetry path since recover() cannot catch it); a wide context was also fully
materialized before the field cap applied.

Add maxContextDepth (32) plus stack-based cycle detection for the only
self-referenceable container types (map[string]any, []any), threaded through
the flatten helpers. This hardens both the EVP flagevaluation and the older
exposure flattening paths. Output is unchanged for legitimate contexts
(depth <= 32, no cycles); shared sub-trees (diamonds) are still fully flattened.

dd-trace-rb fixed the same defect class in #5896 (commit f8e718).

Codex-scan finding: d4c4823ac5148191bd9869bb77f0227e
Refs: APMSP-3616, FFL-2651
@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Jul 1, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 46.30%
Overall Coverage: 62.92% (+11.68%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 244502d | Docs | Datadog PR Page | Give us feedback!

Drop the cycle-detection machinery (reflect-based identity tracking, seen set,
enterContainer helper) from the previous commit. The fatal failure is the stack
overflow, and the depth cap alone prevents it: a self-referential context simply
recurses to maxContextDepth and stops. Any bounded junk keys a cycle produces are
pruned by the existing 256-field limit.

This keeps the switch and helpers structurally identical to the original, adding
only a threaded depth int and one guard.
@pr-commenter

pr-commenter Bot commented Jul 1, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-01 17:10:14

Comparing candidate commit 244502d in PR branch leo.romanovsky/ffl-2651-openfeature-context-flatten-guard with baseline commit 40a2a62 in branch main.

Found 0 performance improvements and 0 performance regressions! Performance is the same for 324 metrics, 2 unstable metrics, 1 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

Known flaky benchmarks without significant changes:

  • scenario:BenchmarkOTLPTraceWriterFlush

Satisfies golangci-lint modernize (rangeint); the loop counters were unused.
The depth cap alone fixes the fatal stack overflow but not the memory/CPU
amplification the security report also called out: a context that shares a child
map/slice across many keys (a DAG) fans out ~2^depth before the 256-field prune
runs, and a self-referential map produces bounded-but-junk keys.

Restore the two guards from the original finding alongside the depth cap:
- cycle detection: track map[string]any / []any identity on the recursion stack
  and skip references already in progress (breaks true cycles; diamonds still
  flatten fully since identity is removed on the way out).
- maxFlattenFields (65536): a total-field safety ceiling that bounds DAG fan-out.
  It sits far above the 256-field intake prune, so real contexts are unaffected
  and the deterministic prune that builds the aggregation bucket key is unchanged;
  only pathological amplification is truncated.

All of this runs on the background aggregation worker, not the evaluation hot
path, so there is no per-evaluation cost.

Tests are table-driven in flatten_test.go: normal nesting, direct/indirect
cycles, diamond (not a cycle), depth-bounded map/array, and field-ceiling fan-out.

Refs: APMSP-3616, FFL-2651
@leoromanovsky leoromanovsky marked this pull request as ready for review July 1, 2026 17:02
@leoromanovsky leoromanovsky requested a review from a team as a code owner July 1, 2026 17:02
@leoromanovsky leoromanovsky requested review from dd-oleksii and sameerank and removed request for a team July 1, 2026 17:02
@leoromanovsky

Copy link
Copy Markdown
Contributor Author

@codex Review this for correctness and canonical golang code. Validate that this operation, while expensive, is off the flag evaluation hot path and won't directly impact the user experience.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 244502d64f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread openfeature/flatten.go
// 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 👍 / 👎.

Comment thread openfeature/flatten.go
Comment on lines +115 to +116
ptr := reflect.ValueOf(v).Pointer()
if _, cyclic := seen[ptr]; cyclic {

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant