Skip to content

fix: Match attribute references against String and Symbol hash keys - #422

Draft
keelerm84 wants to merge 4 commits into
mainfrom
mk/sdk-2951/private-attribute-key-mismatch
Draft

fix: Match attribute references against String and Symbol hash keys#422
keelerm84 wants to merge 4 commits into
mainfrom
mk/sdk-2951/private-attribute-key-mismatch

Conversation

@keelerm84

Copy link
Copy Markdown
Member

Reference stores every attribute path component as a Symbol, but context
attribute data keeps whatever key type the application used. JSON.parse
makes String keys by default, and create_single_context clones each
attribute value shallowly, so nested String keys survive into the SDK.

Every site that resolved a reference component against that data compared
the two with ==, and :email does not equal "email". Three defects followed
from that one mismatch:

  • A nested private attribute was not redacted, and its path was absent
    from _meta.redactedAttributes, so private data reached the event
    recorder.
  • A nested attribute reference resolved to nil during evaluation, so
    targeting rules and bucketing on a path such as /profile/email never
    matched and the flag returned the wrong variation.
  • A String top-level custom attribute key serialized as null in events.

Two helpers in Impl::Context now handle both key types. Value lookups use
fetch_attribute, which prefers the Symbol key when a hash holds both
forms of a name. Redaction uses same_attribute_name?, which matches both
forms, so an ambiguous hash loses both values. Redaction fails closed.

same_attribute_name? compares with == before to_s, so the Symbol path
allocates nothing. redact_json_value runs once per event.

Reference stores every attribute path component as a Symbol, but context
attribute data keeps whatever key type the application used. JSON.parse
makes String keys by default, and create_single_context clones each
attribute value shallowly, so nested String keys survive into the SDK.

Every site that resolved a reference component against that data compared
the two with ==, and :email does not equal "email". Three defects followed
from that one mismatch:

- A nested private attribute was not redacted, and its path was absent
  from _meta.redactedAttributes, so private data reached the event
  recorder.
- A nested attribute reference resolved to nil during evaluation, so
  targeting rules and bucketing on a path such as /profile/email never
  matched and the flag returned the wrong variation.
- A String top-level custom attribute key serialized as null in events.

Two helpers in Impl::Context now handle both key types. Value lookups use
fetch_attribute, which prefers the Symbol key when a hash holds both
forms of a name. Redaction uses same_attribute_name?, which matches both
forms, so an ambiguous hash loses both values. Redaction fails closed.

same_attribute_name? compares with == before to_s, so the Symbol path
allocates nothing. redact_json_value runs once per event.
The contract test service parses harness input with symbolize_names, so
nested application data always reaches LDContext.create with symbol keys.
No contract test can produce the String keyed shape, and the harness has
no way to express a Ruby type distinction. These specs are the only
coverage for it.

Each of the nine new examples fails against the code before the fix.

Also report a redacted reference once when a hash holds both the string
and the symbol form of the same name. Both values are redacted, but the
reference names one attribute.
fetch_attribute tried the name as given and then its string form. That
covers a symbol name against a string keyed hash, which is what the two
call sites need, because both pass a Reference component and those are
always symbols.

A string name got no such treatment. The string form of a string is the
same string, so the second lookup repeated the first and a symbol keyed
hash read as absent. That is the common shape of context attribute data,
so a future caller holding a name as a string would have hit it.

Try the exact key, then the other form. same_attribute_name? was already
symmetric, since it normalizes both sides with to_s, and the pair now
reads consistently.
@keelerm84

Copy link
Copy Markdown
Member Author

One behaviour this change makes worse, flagged for the reviewer rather than buried.

A context can mix a Symbol :_meta with a String '_meta', for example when an application
merges a Symbol-keyed base hash with a fragment that came from JSON.parse. The String form
is not read as metadata. It is demoted to an ordinary custom attribute, so the
privateAttributes it carries are ignored, and that part is unchanged here.

What changed is what gets emitted. Before, the String twin was unreadable and went out as
null. Now that top-level String keys resolve, the caller's object is emitted, so the
names of intended-private attributes reach the event payload:

before: {"key":"k","kind":"user","_meta":null,"_meta":{"redactedAttributes":["email"]}}
after:  {"key":"k","kind":"user","_meta":{"privateAttributes":["ssn"]},"_meta":{"redactedAttributes":["email"]}}

The values leaked in both cases, since that _meta was never honoured. The delta is the
names. Duplicate JSON properties are legal and a parser keeps the last one, so the event
recorder still reads the real _meta.

The same shadowing affects 'name', 'anonymous', 'kind' and 'key', which all predate
this change. The skip list in create_single_context matches Symbols only, so a String twin
falls through into the attributes hash and is then intercepted by the builtin lookup. Adding
the String forms to that list fixes the whole family:

        case k
        when :kind, :key, :name, :anonymous, :_meta,
             'kind', 'key', 'name', 'anonymous', '_meta'
          next

Prototyped and green, but deliberately not in this PR, because it widens a security fix.
Happy to fold it in here or take it as a follow-up ticket, whichever the reviewer prefers.

The build-linux (jruby-9.4) job started failing with no code change,
purely from RuboCop floating 1.89.0 to 1.90.0, which released
2026-08-24. The step emitted 11944 internal cop errors across nearly
every file in the repository, including files the branch never touched,
and exited 2. The CRuby 3.2, CRuby 3.4, and Windows jobs pass on the
same 1.90.0.

On JRuby the parallel gem cannot fork, so it runs work in threads
rather than worker processes. RuboCop 1.90 began preserving cop
instances across files, which is safe per worker process but not across
threads, so the reused instances see concurrent processed_source values
and report locations from the wrong file.

Parallelism buys nothing here. Serial and parallel both take 4.5
seconds over 185 files with the cache disabled.

This mirrors the same change in the openfeature-ruby-server SDK.
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