From 325a022f05ff9648dfacb4ba9117ad794922c0e2 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 29 Sep 2026 12:12:16 -0500 Subject: [PATCH 1/3] fix: Escape attribute names reported in redactedAttributes When a whole attribute is redacted because allAttributesPrivate is set, or because the context is anonymous and anonymous redaction is enabled, the raw attribute name was reported in _meta.redactedAttributes. A name that starts with a slash, such as "/ssn", was therefore reported as "/ssn", which a consumer reads as a path to a nested property "ssn". The redaction of the top-level attribute was not communicated. It is now reported as the escaped attribute reference "/~1ssn". Only the reported reference was wrong. The attribute value was never sent. --- .../events/EventContextFormatter.java | 9 +-- .../events/EventContextFormatterTest.java | 68 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java index 878ed791..18a484c0 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -103,10 +103,11 @@ private List writeOrRedactAttribute( List redacted, boolean redactAnonymous ) throws IOException { - if (allAttributesPrivate) { - return addOrCreate(redacted, attrName); - } else if (redactAnonymous && c.isAnonymous()) { - return addOrCreate(redacted, attrName); + if (allAttributesPrivate || (redactAnonymous && c.isAnonymous())) { + // The name must be reported as an escaped attribute reference. Otherwise a name that + // starts with a slash reads as a path to a nested property, and the consumer does not + // see which attribute was redacted. + return addOrCreate(redacted, AttributeRef.fromLiteral(attrName).toString()); } return writeRedactedValue(w, c, 0, attrName, value, null, redacted); } diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index 8e685f67..9fa96615 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -24,6 +24,7 @@ public class EventContextFormatterTest extends BaseTest { private final LDContext context; private final boolean allAttributesPrivate; + private final boolean redactAnonymous; private final AttributeRef[] globalPrivateAttributes; private final String expectedJson; @@ -31,11 +32,13 @@ public EventContextFormatterTest( String name, LDContext context, boolean allAttributesPrivate, + boolean redactAnonymous, AttributeRef[] globalPrivateAttributes, String expectedJson ) { this.context = context; this.allAttributesPrivate = allAttributesPrivate; + this.redactAnonymous = redactAnonymous; this.globalPrivateAttributes = globalPrivateAttributes; this.expectedJson = expectedJson; } @@ -53,6 +56,7 @@ public static Iterable data() { .set("attr1", "value1") .build(), false, + false, new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"name\": \"my-name\", \"attr1\": \"value1\"}" }, @@ -68,6 +72,7 @@ public static Iterable data() { .build() ), false, + false, new AttributeRef[0], "{" + "\"kind\": \"multi\"," + @@ -79,6 +84,7 @@ public static Iterable data() { "anonymous", LDContext.builder("my-key").kind("org").anonymous(true).build(), false, + false, new AttributeRef[0], "{\"kind\": \"org\", \"key\": \"my-key\", \"anonymous\": true}" }, @@ -89,6 +95,7 @@ public static Iterable data() { .set("attr1", "value1") .build(), true, + false, new AttributeRef[0], "{" + "\"kind\": \"org\"," + @@ -107,6 +114,7 @@ public static Iterable data() { .privateAttributes("attr2") .build(), false, + false, new AttributeRef[] { AttributeRef.fromLiteral("name") }, "{" + "\"kind\": \"org\"," + @@ -125,6 +133,7 @@ public static Iterable data() { .privateAttributes("/complex/a/b/d", "/complex/a/b/nonexistent-prop", "/complex/f", "/complex/g/g-is-not-an-object") .build(), false, + false, new AttributeRef[] { AttributeRef.fromPath("/address/street") }, "{" + "\"kind\": \"user\"," + @@ -135,6 +144,63 @@ public static Iterable data() { "\"redactedAttributes\": [\"/address/street\", \"/complex/a/b/d\", \"/complex/f\"]" + "}" + "}" + }, + // A name that starts with a slash is reported as an escaped reference. Any other name + // is already a valid reference and is reported unchanged. + new Object[] { + "all attributes private globally - names needing escaping", + LDContext.builder("my-key").kind("org") + .set("/ssn", "123-45-6789") + .set("/a~b", "secret") + .set("c/d~e", "value") + .build(), + true, + false, + new AttributeRef[0], + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"/~1a~0b\", \"/~1ssn\", \"c/d~e\"]" + + "}" + + "}" + }, + new Object[] { + "redacting anonymous context - names needing escaping", + LDContext.builder("my-key").kind("org") + .anonymous(true) + .name("my-name") + .set("/ssn", "123-45-6789") + .build(), + false, + true, + new AttributeRef[0], + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"anonymous\": true," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"/~1ssn\", \"name\"]" + + "}" + + "}" + }, + new Object[] { + "slash-prefixed attribute name configured as private", + LDContext.builder("my-key").kind("org") + .name("my-name") + .set("/ssn", "123-45-6789") + .build(), + false, + false, + new AttributeRef[] { AttributeRef.fromLiteral("/ssn") }, + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"name\": \"my-name\"," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"/~1ssn\"]" + + "}" + + "}" } ); } @@ -145,7 +211,7 @@ public void testOutput() throws Exception { StringWriter sw = new StringWriter(); JsonWriter jw = new JsonWriter(sw); - f.write(context, jw, false); + f.write(context, jw, redactAnonymous); jw.flush(); String canonicalizedOutput = canonicalizeOutputJson(sw.toString()); From 3f27cb76f4d6fa8f515483af916b2237652042cd Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 29 Sep 2026 12:14:11 -0500 Subject: [PATCH 2/3] test: Cover escaped references for per-context private attributes The depth-1 private-reference match already reports the configured reference, which AttributeRef always stores in escaped form. This case guards that behavior for a per-context private attribute. --- .../events/EventContextFormatterTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java index 9fa96615..ac9369ce 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventContextFormatterTest.java @@ -201,6 +201,25 @@ public static Iterable data() { "\"redactedAttributes\": [\"/~1ssn\"]" + "}" + "}" + }, + new Object[] { + "slash-prefixed attribute name private for this context", + LDContext.builder("my-key").kind("org") + .name("my-name") + .set("/ssn", "123-45-6789") + .privateAttributes("/~1ssn") + .build(), + false, + false, + new AttributeRef[0], + "{" + + "\"kind\": \"org\"," + + "\"key\": \"my-key\"," + + "\"name\": \"my-name\"," + + "\"_meta\": {" + + "\"redactedAttributes\": [\"/~1ssn\"]" + + "}" + + "}" } ); } From 3011d662c8d12af0d8b650bb13dcf706390f1597 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 29 Sep 2026 15:39:43 -0500 Subject: [PATCH 3/3] style: Shorten the redaction comment to one line --- .../sdk/internal/events/EventContextFormatter.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java index 18a484c0..80dc30ec 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventContextFormatter.java @@ -104,9 +104,7 @@ private List writeOrRedactAttribute( boolean redactAnonymous ) throws IOException { if (allAttributesPrivate || (redactAnonymous && c.isAnonymous())) { - // The name must be reported as an escaped attribute reference. Otherwise a name that - // starts with a slash reads as a path to a nested property, and the consumer does not - // see which attribute was redacted. + // An escaped reference keeps a leading slash from reading as a path. return addOrCreate(redacted, AttributeRef.fromLiteral(attrName).toString()); } return writeRedactedValue(w, c, 0, attrName, value, null, redacted);