Skip to content

fix: Escape attribute names reported in redactedAttributes - #415

Open
kinyoklion wants to merge 2 commits into
mainfrom
devin/1786660448-redacted-attribute-escaping
Open

fix: Escape attribute names reported in redactedAttributes#415
kinyoklion wants to merge 2 commits into
mainfrom
devin/1786660448-redacted-attribute-escaping

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Aug 13, 2026

Copy link
Copy Markdown
Member

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Surfaced by contract test harness v3.2.0-alpha.6 (the pin bump in #414). These 6 subtests failed against the newer harness:

  • events/context properties/single-kind, allAttributesPrivate, slash-prefixed attribute name (debug, identify, index-from-evaluation, index-from-custom-event)
  • events/feature events/single-kind anonymous context redacts all attributes/type: any
  • events/feature events/multi-kind with anonymous context redacts attributes appropriately/type: any

Describe the solution you've provided

_meta.redactedAttributes in event payloads is a list of attribute references, not raw attribute names. When a context has an attribute whose name begins with / (e.g. /ssn), the SDK emitted the raw name, which a consumer parses as a path expression pointing at a nested property rather than the top-level attribute:

expected: "/~1ssn"
actual:   "/ssn"

ContextFilter#check_whole_attribute_private now converts the attribute name to a reference with Reference.create_literal before adding it to the redacted list, which escapes / and ~ for slash-prefixed names and leaves all other names unchanged (name stays name, a/b~c stays a/b~c since it is already a literal reference). This covers both the allAttributesPrivate/configured-private paths and the anonymous-context redaction path, which is why one change fixes all 6 subtests.

Verified locally: contract test service against released harness v3.2.0-alpha.6, full suite — 4723 total, 14 skipped, all ran passed.

Describe alternatives you've considered

Escaping only in the all_attributes_private branch — rejected, the same escaping is required wherever a whole attribute is redacted.

Additional context

Nested redactions (redact_json_value) already reported Reference#raw_path, so they were unaffected.

Link to Devin session: https://app.devin.ai/sessions/316afaccd2604f8d802a72c9080f6921
Requested by: @kinyoklion


Note

Overview
Fixes _meta.redactedAttributes in event payloads so entries are escaped attribute references, not raw names. Attributes whose names start with / (e.g. /ssn) were emitted as /ssn, which consumers parse as a nested path instead of a top-level literal.

ContextFilter now passes Reference.create_literal into check_whole_attribute_private for name and custom attributes, and records attribute.raw_path.to_sym in the redacted list. That aligns whole-attribute redaction with redact_json_value, which already used raw_path.

The same path covers all-attributes-private, configured private attributes, and anonymous context redaction. Tests assert escaped values such as "/~1ssn" for slash-prefixed names.

Reviewed by Cursor Bugbot for commit 5814e95. Bugbot is set up for automated code reviews on this repo. Configure here.

Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
@kinyoklion kinyoklion self-assigned this Aug 13, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Contributor

@cursor review

@kinyoklion
kinyoklion marked this pull request as ready for review August 17, 2026 17:56
@kinyoklion
kinyoklion requested a review from a team as a code owner August 17, 2026 17:56
kinyoklion added a commit that referenced this pull request Aug 18, 2026
…ntexts (#416)

**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests)
- [ ] I have validated my changes against all supported platform
versions

**Related issues**

Found while investigating the contract test failures fixed in #415. Not
covered by the current contract test suite.

**Describe the solution you've provided**

`ContextFilter#filter_single_context` combined the globally configured
private attributes with the context's own `_meta.privateAttributes`
using `Array#concat`, which mutates `@private_attributes` in place.
Every context filtered by a `ContextFilter` therefore permanently added
its private attributes to the filter's configuration, so a context's
private attributes were applied to all contexts filtered afterwards —
including the other kinds of the same multi-kind context.

Before (single `ContextFilter.new(false, [])`):

```ruby
filter.filter(user_with_private_email)  # {..., _meta: {redactedAttributes: [:email]}}
filter.filter(other_user)               # {..., _meta: {redactedAttributes: [:email]}}  <- email was not private here
```

The fix builds a new array (`@private_attributes +
context.private_attributes`) instead of mutating the configured list.
Event processors reuse a single `ContextFilter` for the lifetime of the
client, so the leak was cumulative across all events.

**Describe alternatives you've considered**

Constructing the filter per event — unnecessary allocation churn, and
the mutation is the actual defect.

**Additional context**

Full contract test suite against harness v3.2.0-alpha.6 passes with this
change (combined with #415): 4723 total, 14 skipped, all ran passed.


Link to Devin session:
https://app.devin.ai/sessions/316afaccd2604f8d802a72c9080f6921
Requested by: @kinyoklion

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Fixes a privacy bug in `ContextFilter#filter_single_context`:**
merging global private attributes with each context’s
`_meta.privateAttributes` used `Array#concat`, which mutated the
filter’s `@private_attributes`. Because `EventOutputFormatter` keeps one
`ContextFilter` for the client lifetime, later contexts (and other kinds
in the same multi-kind context) could incorrectly redact attributes that
were only private on a previous context.
> 
> The change uses `@private_attributes + context.private_attributes` so
the configured list is never mutated. New specs cover sequential
filtering and multi-kind isolation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
878e2e9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Comment thread lib/ldclient-rb/impl/context_filter.rb Outdated
private def check_whole_attribute_private(attribute, private_attributes, redacted, redact_all)
if @all_attributes_private || redact_all
redacted << attribute
redacted << redaction_name(attribute)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I would expect this to have been done earlier in the process than here? Is any other functionality impacted by this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Moved it earlier: filter_single_context now builds Reference.create_literal(attribute) where the attribute names are enumerated, and check_whole_attribute_private takes that reference (matching on component(0), reporting raw_path).

Nothing else is impacted. ContextFilter is the only consumer of get_custom_attribute_names, and it's the only place that turns a plain attribute name into a reported reference — nested redaction already reported private_attribute.raw_path, and LDContext#to_h/private_attributes already store references. The other create_literal callers (clause attributes, bucket_by) are unrelated to event serialization.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I just want an answer, but not further changes. Was check_whole_attribute_private already working fine with strings, did it know internally that it needed to handle this nuance.

Does the SDK access fields by attribute reference? Is it accessing those fields correctly regardless of reference format? Does it still support literals via a user type, or does it only support contexts where the input should be references?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Answers only, no further changes:

Was check_whole_attribute_private already fine? Yes, for matching. It compares private_attribute.component(0) (a parsed reference component, already unescaped) against the raw attribute name, so a configured private attribute of /~1ssn correctly matched the literal attribute named /ssn. The only defect was reporting: the raw name was pushed into redactedAttributes instead of its reference form. So the change is output-only — nothing about the match logic needed the nuance, and the bug was invisible unless an attribute name contained / or ~.

Does the SDK access fields by attribute reference? Yes — LDContext#get_value_for_reference is the single accessor and it walks Reference components, so lookups are correct regardless of reference format. get_value(name) is just get_value_for_reference(Reference.create_literal(name)), which is why ContextFilter reading /ssn as a literal always worked.

Literals vs references at the input boundary: literal handling is retained where the input historically wasn't a reference — Clause#attribute and bucket_by use create_literal when contextKind is absent (old user-schema flag rules) and Reference.create when it's present. Everything context-facing treats input as references: Config#private_attributes and _meta.privateAttributes are parsed with Reference.create, and to_h emits them as raw_path. redactedAttributes is the same wire field family, hence the escaping here.

Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
private_attributes.each do |private_attribute|
if private_attribute.component(0) == attribute && private_attribute.depth == 1
redacted << attribute
if private_attribute.component(0) == attribute.component(0) && private_attribute.depth == 1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

How does this component check work in the case of a path? /address/street for example? Just answer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It never fires for a path. attribute here is always a top-level attribute name wrapped with create_literal, so it's depth 1, and the loop also requires private_attribute.depth == 1. A private attribute of /address/street has depth 2, so this check skips it and redact_json_value handles it by walking addressstreet and reporting private_attribute.raw_path (/address/street) — unchanged by this PR.

component(0) on both sides is just "the unescaped top-level name", so the two depth-1 cases still line up:

  • private address (or /address) vs. attribute named address → both component(0) == :address → match.
  • private /~1address~1street vs. a top-level attribute literally named /address/street → the private reference parses to depth 1 with component(0) == :"/address/street", and create_literal("/address/street") gives the same component(0) with raw_path /~1address~1street → match, reported escaped.

Comparing component(0) rather than the raw name is what makes the second case work; the old code compared component(0) against the raw name, which happened to work for matching but reported the unescaped name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It feels strange to me that there are two paths. What is the root reason for that asymmetry? The root storage being different than the storage within an attribute?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The asymmetry predates this PR and comes from the two paths starting from different things, in opposite directions:

  • Top level (check_whole_attribute_private) iterates over attribute names the context holds and asks "is this name private?". Names are arbitrary strings that are not references (/ssn is a legal attribute name), so a reference has to be constructed from the name to report it — that construction is the escaping this PR adds. It also has redaction triggers that only exist at level 0: allAttributesPrivate and anonymous redaction, plus the built-in/non-addressable keys (key, kind, anonymous, _meta) that must never be redacted and name, which is special-cased.
  • Inside an attribute (redact_json_value) iterates over private attribute references against arbitrary JSON. It already holds a reference that matched, so it reports raw_path directly — nothing to construct — and it can't drop the value outright; it has to rebuild the object minus redacted keys.

So it's less "root vs. nested storage" and more: level 0 is a name-keyed context with special-cased keys and blanket-redaction rules, level 1+ is plain JSON addressed only by reference components. Unifying them would mean treating the context as a JSON object one level up, which the special keys at level 0 make awkward — and the spec's escaping rule for names only exists because of that level-0 name-to-reference conversion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sure sounds like roost vs nested. Where root is a name-keyed context with special-cased keys, and nested items are JSON.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed — root vs. nested is the right framing, and my wording was splitting hairs. Root is a name-keyed context with special-cased keys (hence the name→reference escaping this PR adds); nested is plain JSON already addressed by references.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant