Skip to content

Commit 90059cb

Browse files
authored
fix: Escape attribute names reported in redactedAttributes (#505)
1 parent 0eb61fa commit 90059cb

4 files changed

Lines changed: 46 additions & 3 deletions

File tree

ldclient/impl/events/event_context_formatter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,11 @@ def _format_context_single(self, context: Context, include_kind: bool, redact_an
7676

7777
def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted: List[str], redact_all: bool) -> bool:
7878
if self._all_attributes_private or redact_all:
79-
redacted.append(attr)
79+
redacted.append(AttributeRef.from_literal(attr).path)
8080
return True
8181
for p in all_private:
8282
if p.depth == 1 and p[0] == attr:
83-
redacted.append(attr)
83+
redacted.append(AttributeRef.from_literal(attr).path)
8484
return True
8585
return False
8686

ldclient/impl/model/attribute_ref.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,13 @@ def from_path(path: str) -> AttributeRef:
7777
def from_literal(name: str) -> AttributeRef:
7878
if name == '':
7979
return AttributeRef._from_error(AttributeRef._ERR_EMPTY)
80-
return AttributeRef(AttributeRef._escape(name), name, None, None)
80+
if name[0] != '/':
81+
# A name that does not start with a slash is already a valid
82+
# reference to a top-level attribute. It needs no escaping.
83+
return AttributeRef(name, name, None, None)
84+
# A name that starts with a slash must be escaped. If it is not, a
85+
# consumer reads it as a path to a nested property.
86+
return AttributeRef('/' + AttributeRef._escape(name), name, None, None)
8187

8288
@staticmethod
8389
def _from_error(error: str) -> AttributeRef:

ldclient/testing/impl/events/test_event_context_formatter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,21 @@ def test_private_property_in_object():
6161
f = EventContextFormatter(False, ['/b/prop1', '/c/prop2/sub1'])
6262
c = Context.builder('a').set('b', {'prop1': True, 'prop2': 3}).set('c', {'prop1': {'sub1': True}, 'prop2': {'sub1': 4, 'sub2': 5}}).build()
6363
assert f.format_context(c) == {'kind': 'user', 'key': 'a', 'b': {'prop2': 3}, 'c': {'prop1': {'sub1': True}, 'prop2': {'sub2': 5}}, '_meta': {'redactedAttributes': ['/b/prop1', '/c/prop2/sub1']}}
64+
65+
66+
def test_all_private_reports_escaped_references():
67+
f = EventContextFormatter(True, [])
68+
c = Context.builder('a').set('/ssn', '123-45-6789').set('/a~b', 'secret').set('c/d~e', 'plain').build()
69+
assert f.format_context(c) == {'kind': 'user', 'key': 'a', '_meta': {'redactedAttributes': ['/~1ssn', '/~1a~0b', 'c/d~e']}}
70+
71+
72+
def test_redact_anonymous_reports_escaped_references():
73+
f = EventContextFormatter(False, [])
74+
c = Context.builder('a').name('b').anonymous(True).set('/ssn', '123-45-6789').build()
75+
assert f.format_context_redact_anonymous(c) == {'kind': 'user', 'key': 'a', 'anonymous': True, '_meta': {'redactedAttributes': ['name', '/~1ssn']}}
76+
77+
78+
def test_private_slash_prefixed_attribute_reports_escaped_reference():
79+
f = EventContextFormatter(False, ['/~1ssn'])
80+
c = Context.builder('a').name('b').set('/ssn', '123-45-6789').build()
81+
assert f.format_context(c) == {'kind': 'user', 'key': 'a', 'name': 'b', '_meta': {'redactedAttributes': ['/~1ssn']}}

ldclient/testing/impl/test_attribute_ref.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,25 @@ def test_literal(self, input: str):
4242
assert a.depth == 1
4343
assert a[0] == input
4444

45+
@pytest.mark.parametrize(
46+
"input,expected_path",
47+
[
48+
("name", "name"),
49+
("name/with/slashes", "name/with/slashes"),
50+
("a/b~c", "a/b~c"),
51+
("/ssn", "/~1ssn"),
52+
("/a~b", "/~1a~0b"),
53+
],
54+
)
55+
def test_literal_path_escapes_a_leading_slash(self, input: str, expected_path: str):
56+
# A name that starts with a slash must be escaped, so that a consumer
57+
# does not read it as a path to a nested property. Any other name is
58+
# already a valid reference and stays unchanged.
59+
a = AttributeRef.from_literal(input)
60+
assert a.path == expected_path
61+
assert AttributeRef.from_path(a.path).path == expected_path
62+
assert AttributeRef.from_path(a.path)[0] == input
63+
4564
def test_get_component(self):
4665
a = AttributeRef.from_path("/first/sec~1ond/third")
4766
assert a.depth == 3

0 commit comments

Comments
 (0)