From a4513ece259a0a54239276a86e2da2a95d2237cd Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 20 Aug 2026 13:47:56 -0400 Subject: [PATCH 1/4] fix: Match attribute references against String and Symbol hash keys 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. --- lib/ldclient-rb/context.rb | 9 ++++--- lib/ldclient-rb/impl/context.rb | 34 ++++++++++++++++++++++++++ lib/ldclient-rb/impl/context_filter.rb | 4 +-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/ldclient-rb/context.rb b/lib/ldclient-rb/context.rb index 081d61a2..da8e357c 100644 --- a/lib/ldclient-rb/context.rb +++ b/lib/ldclient-rb/context.rb @@ -230,9 +230,9 @@ def get_value_for_reference(reference) name = reference.component(i) return nil unless value.is_a?(Hash) - return nil unless value.has_key?(name) - value = value[name] + found, value = LaunchDarkly::Impl::Context.fetch_attribute(value, name) + return nil unless found end value @@ -418,7 +418,10 @@ def to_h when :anonymous @anonymous else - @attributes&.fetch(name, nil) + return nil if @attributes.nil? + + _, value = LaunchDarkly::Impl::Context.fetch_attribute(@attributes, name) + value end end diff --git a/lib/ldclient-rb/impl/context.rb b/lib/ldclient-rb/impl/context.rb index 8ff6820b..ed780dab 100644 --- a/lib/ldclient-rb/impl/context.rb +++ b/lib/ldclient-rb/impl/context.rb @@ -78,6 +78,40 @@ def self.validate_anonymous(anonymous, allow_nil) ERR_ANONYMOUS_NON_BOOLEAN end + # + # Attribute reference components are always symbols, but application data + # can use string keys. For example, JSON.parse makes string keys by + # default. The two forms name the same JSON property, so this method + # compares them as equal. + # + # @param component [Symbol] + # @param key [any] + # @return [Boolean] + # + def self.same_attribute_name?(component, key) + component == key || component.to_s == key.to_s + end + + # + # Read an attribute out of a hash by reference component name. The hash + # can use either symbol or string keys. A symbol key takes precedence. + # + # The first element of the returned array is true if the hash has the + # attribute. The second element is the value, or nil if there is none. + # + # @param hash [Hash] + # @param name [Symbol] + # @return [Array(Boolean, any)] + # + def self.fetch_attribute(hash, name) + return true, hash[name] if hash.has_key?(name) + + string_name = name.to_s + return true, hash[string_name] if hash.has_key?(string_name) + + [false, nil] + end + # # @param kind [String] # @param key [String] diff --git a/lib/ldclient-rb/impl/context_filter.rb b/lib/ldclient-rb/impl/context_filter.rb index 6fe8d68c..19a0e236 100644 --- a/lib/ldclient-rb/impl/context_filter.rb +++ b/lib/ldclient-rb/impl/context_filter.rb @@ -137,11 +137,11 @@ def filter_redact_anonymous(context) next unless private_attribute.depth == (current_path.count + 1) component = private_attribute.component(current_path.count) - next unless component == k + next unless LaunchDarkly::Impl::Context.same_attribute_name?(component, k) match = true (0...current_path.count).each do |i| - unless private_attribute.component(i) == current_path[i] + unless LaunchDarkly::Impl::Context.same_attribute_name?(private_attribute.component(i), current_path[i]) match = false break end From 3c1c5853df1780653fb3a4c2f0867ffb9af4602e Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 20 Aug 2026 13:52:13 -0400 Subject: [PATCH 2/4] test: Cover String keyed attribute data in privacy and lookup specs 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. --- lib/ldclient-rb/impl/context_filter.rb | 5 +- spec/context_spec.rb | 32 ++++++++++ spec/impl/context_filter_spec.rb | 83 ++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/lib/ldclient-rb/impl/context_filter.rb b/lib/ldclient-rb/impl/context_filter.rb index 19a0e236..e6ffa84c 100644 --- a/lib/ldclient-rb/impl/context_filter.rb +++ b/lib/ldclient-rb/impl/context_filter.rb @@ -84,7 +84,10 @@ def filter_redact_anonymous(context) end end - filtered[:_meta] = {redactedAttributes: redacted} unless redacted.empty? + # A hash can hold both the string and the symbol form of one name. Each + # form redacts a value, but the reference names one attribute, so report + # it once. + filtered[:_meta] = {redactedAttributes: redacted.uniq} unless redacted.empty? filtered end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index 44be8e4a..35ae5d14 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -391,6 +391,38 @@ module LaunchDarkly expect(org_context.get_value_for_reference(ref)).to matcher end end + + it "with complex attributes that have string keys" do + address = JSON.parse('{"city":"Oakland","state":"CA","zip":94612}') + tags = ["LaunchDarkly", "Feature Flags"] + nested = JSON.parse('{"upper":{"middle":{"name":"Middle Level","inner":{"levels":[0,1,2]}},"name":"Upper Level"}}') + + org_context = subject.create({ key: 'ld', kind: 'org', name: 'LaunchDarkly', anonymous: true, address: address, tags: tags, nested: nested }) + + [ + ['/address', eq(address)], + ['/address/city', eq('Oakland')], + + ['/tags', eq(tags)], + + ['/nested/upper/name', eq('Upper Level')], + ['/nested/upper/middle/name', eq('Middle Level')], + ['/nested/upper/middle/inner/levels', eq([0, 1, 2])], + ].each do |(reference, matcher)| + ref = Reference.create(reference) + expect(org_context.get_value_for_reference(ref)).to matcher + end + end + + it "with a string top level attribute name" do + address = { city: "Oakland" } + + org_context = subject.create({ :key => 'ld', :kind => 'org', "address" => address }) + + expect(org_context.get_value_for_reference(Reference.create('/address'))).to eq(address) + expect(org_context.get_value_for_reference(Reference.create('/address/city'))).to eq('Oakland') + expect(org_context.get_value("address")).to eq(address) + end end end diff --git a/spec/impl/context_filter_spec.rb b/spec/impl/context_filter_spec.rb index 0c1e205a..e6018288 100644 --- a/spec/impl/context_filter_spec.rb +++ b/spec/impl/context_filter_spec.rb @@ -52,6 +52,89 @@ module Impl expect(filtered["user"][:_meta][:redactedAttributes]).to eq([:email]) expect(filtered["org"]).to eq({ key: "org-key", email: "email" }) end + + describe "with string keyed attribute data" do + it "redacts a nested private attribute" do + filter = ContextFilter.new(false, ["/profile/email"]) + context = LDContext.create({ kind: "user", key: "user-key", profile: JSON.parse('{"email":"private@example.test","plan":"pro"}') }) + + filtered = filter.filter(context) + + expect(filtered[:profile]).to eq({ "plan" => "pro" }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/profile/email"]) + end + + it "redacts a nested per-context private attribute" do + filter = ContextFilter.new(false, []) + context = LDContext.create({ + kind: "user", + key: "user-key", + account: JSON.parse('{"apiToken":"private-token","region":"test-region"}'), + _meta: { privateAttributes: ["/account/apiToken"] }, + }) + + filtered = filter.filter(context) + + expect(filtered[:account]).to eq({ "region" => "test-region" }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/account/apiToken"]) + end + + it "produces the same event payload as symbol keyed data" do + filter = ContextFilter.new(false, ["/profile/email"]) + json = '{"email":"private@example.test","plan":"pro"}' + string_keyed = LDContext.create({ kind: "user", key: "user-key", profile: JSON.parse(json) }) + symbol_keyed = LDContext.create({ kind: "user", key: "user-key", profile: JSON.parse(json, symbolize_names: true) }) + + # The two hashes keep the key type they were given, so compare the + # payloads the way the event sender sees them. A string key and a + # symbol key serialize to the same JSON property name. + expect(JSON.generate(filter.filter(string_keyed))).to eq(JSON.generate(filter.filter(symbol_keyed))) + end + + it "redacts a private attribute nested three levels deep" do + filter = ContextFilter.new(false, ["/a/b/c"]) + context = LDContext.create({ kind: "user", key: "user-key", a: JSON.parse('{"b":{"c":"private","d":"keep"}}') }) + + filtered = filter.filter(context) + + expect(filtered[:a]).to eq({ "b" => { "d" => "keep" } }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/a/b/c"]) + end + + it "redacts both the string and the symbol form of one name, and reports the reference once" do + filter = ContextFilter.new(false, ["/profile/email"]) + context = LDContext.create({ + kind: "user", + key: "user-key", + profile: { :email => "symbol-private", "email" => "string-private", "other" => "keep" }, + }) + + filtered = filter.filter(context) + + expect(filtered[:profile]).to eq({ "other" => "keep" }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/profile/email"]) + end + + it "redacts a nested private attribute under a string top level key" do + filter = ContextFilter.new(false, ["/profile/email"]) + context = LDContext.create({ :kind => "user", :key => "user-key", "profile" => JSON.parse('{"email":"private@example.test","plan":"pro"}') }) + + filtered = filter.filter(context) + + expect(filtered["profile"]).to eq({ "plan" => "pro" }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/profile/email"]) + end + + it "redacts an escaped nested private attribute name" do + filter = ContextFilter.new(false, ["/profile/a~1b"]) + context = LDContext.create({ kind: "user", key: "user-key", profile: JSON.parse('{"a/b":"private","keep":"keep"}') }) + + filtered = filter.filter(context) + + expect(filtered[:profile]).to eq({ "keep" => "keep" }) + expect(filtered[:_meta][:redactedAttributes]).to eq([:"/profile/a~1b"]) + end + end end end end From 15192772fd326abab479438e4efe7f6875718824 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 24 Aug 2026 16:02:09 -0400 Subject: [PATCH 3/4] fix: Look up an attribute name in both key types, not only symbol first 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. --- lib/ldclient-rb/impl/context.rb | 12 ++++++----- spec/impl/context_spec.rb | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/ldclient-rb/impl/context.rb b/lib/ldclient-rb/impl/context.rb index ed780dab..d66a84df 100644 --- a/lib/ldclient-rb/impl/context.rb +++ b/lib/ldclient-rb/impl/context.rb @@ -93,21 +93,23 @@ def self.same_attribute_name?(component, key) end # - # Read an attribute out of a hash by reference component name. The hash - # can use either symbol or string keys. A symbol key takes precedence. + # Read an attribute out of a hash by name. The hash can use symbol or + # string keys, and the name can be either form. An exact match wins, then + # the other form. A symbol therefore takes precedence when the hash holds + # both forms of one name. # # The first element of the returned array is true if the hash has the # attribute. The second element is the value, or nil if there is none. # # @param hash [Hash] - # @param name [Symbol] + # @param name [Symbol, String] # @return [Array(Boolean, any)] # def self.fetch_attribute(hash, name) return true, hash[name] if hash.has_key?(name) - string_name = name.to_s - return true, hash[string_name] if hash.has_key?(string_name) + alternate = name.is_a?(Symbol) ? name.to_s : name.to_sym + return true, hash[alternate] if hash.has_key?(alternate) [false, nil] end diff --git a/spec/impl/context_spec.rb b/spec/impl/context_spec.rb index dd798c25..5f2995bf 100644 --- a/spec/impl/context_spec.rb +++ b/spec/impl/context_spec.rb @@ -30,6 +30,44 @@ module Impl expect(subject.validate_key(input)).to eq(expected) end end + + it "compares attribute names in either direction" do + [ + [:email, "email"], + ["email", :email], + [:email, :email], + ["email", "email"], + ].each do |(component, key)| + expect(subject.same_attribute_name?(component, key)).to be true + end + + expect(subject.same_attribute_name?(:email, "other")).to be false + end + + it "fetches an attribute for either key type by either name type" do + [ + [{ email: "value" }, :email], + [{ email: "value" }, "email"], + [{ "email" => "value" }, :email], + [{ "email" => "value" }, "email"], + ].each do |(hash, name)| + expect(subject.fetch_attribute(hash, name)).to eq([true, "value"]) + end + + expect(subject.fetch_attribute({ email: "value" }, :other)).to eq([false, nil]) + end + + it "prefers an exact match when a hash holds both forms of a name" do + hash = { :email => "symbol", "email" => "string" } + + expect(subject.fetch_attribute(hash, :email)).to eq([true, "symbol"]) + expect(subject.fetch_attribute(hash, "email")).to eq([true, "string"]) + end + + it "reports a missing attribute as absent rather than nil valued" do + expect(subject.fetch_attribute({ email: nil }, :email)).to eq([true, nil]) + expect(subject.fetch_attribute({}, :email)).to eq([false, nil]) + end end end end From a77c648d9039b55cf1cf955fb15bdc1d67545736 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 26 Aug 2026 10:48:53 -0400 Subject: [PATCH 4/4] ci: Run RuboCop serially to avoid JRuby thread-parallel failures 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. --- .github/actions/check/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index e7455238..edd54d69 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -23,7 +23,7 @@ runs: - name: Run RuboCop shell: bash - run: bundle exec rubocop --parallel + run: bundle exec rubocop - name: Build contract tests if: ${{ inputs.flaky != 'true' }}