Skip to content

Commit 768820c

Browse files
fix(scrubber): Remove ip_address instead of replacing with [Filtered]
The EventScrubber replaced ip_address with [Filtered], which is not a valid IP address and causes a protocol violation on the server side. Add a remove_list parameter to EventScrubber that controls which keys are deleted entirely rather than substituted. Defaults to DEFAULT_REMOVE_LIST = ['ip_address'], so the fix works out of the box. Users can override this via the remove_list constructor argument. Fixes #5701
1 parent 4cd6752 commit 768820c

2 files changed

Lines changed: 104 additions & 4 deletions

File tree

sentry_sdk/scrubber.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757
"remote_addr",
5858
]
5959

60+
# Fields that must be removed entirely rather than replaced with [Filtered],
61+
# because their value must conform to a specific format (e.g. a valid IP address)
62+
# and [Filtered] would be a protocol violation.
63+
DEFAULT_REMOVE_LIST = [
64+
"ip_address",
65+
]
66+
6067

6168
class EventScrubber:
6269
def __init__(
@@ -65,6 +72,7 @@ def __init__(
6572
recursive: bool = False,
6673
send_default_pii: bool = False,
6774
pii_denylist: "Optional[List[str]]" = None,
75+
remove_list: "Optional[List[str]]" = None,
6876
) -> None:
6977
"""
7078
A scrubber that goes through the event payload and removes sensitive data configured through denylists.
@@ -73,6 +81,10 @@ def __init__(
7381
:param recursive: Whether to scrub the event payload recursively, default False.
7482
:param send_default_pii: Whether pii is sending is on, pii fields are not scrubbed.
7583
:param pii_denylist: The denylist to use for scrubbing when pii is not sent, defaults to DEFAULT_PII_DENYLIST.
84+
:param remove_list: Keys that should be removed entirely instead of being replaced
85+
with ``[Filtered]``. This is necessary for fields like ``ip_address`` whose
86+
values must conform to a specific format; replacing them with ``[Filtered]``
87+
would be a protocol violation. Defaults to DEFAULT_REMOVE_LIST.
7688
"""
7789
self.denylist = DEFAULT_DENYLIST.copy() if denylist is None else denylist
7890

@@ -85,6 +97,11 @@ def __init__(
8597
self.denylist = [x.lower() for x in self.denylist]
8698
self.recursive = recursive
8799

100+
self.remove_list = (
101+
DEFAULT_REMOVE_LIST.copy() if remove_list is None else remove_list
102+
)
103+
self.remove_list = [x.lower() for x in self.remove_list]
104+
88105
def scrub_list(self, lst: object) -> None:
89106
"""
90107
If a list is passed to this method, the method recursively searches the list and any
@@ -109,15 +126,22 @@ def scrub_dict(self, d: object) -> None:
109126
if not isinstance(d, dict):
110127
return
111128

129+
keys_to_remove = []
112130
for k, v in d.items():
113131
# The cast is needed because mypy is not smart enough to figure out that k must be a
114132
# string after the isinstance check.
115133
if isinstance(k, str) and k.lower() in self.denylist:
116-
d[k] = AnnotatedValue.substituted_because_contains_sensitive_data()
134+
if k.lower() in self.remove_list:
135+
keys_to_remove.append(k)
136+
else:
137+
d[k] = AnnotatedValue.substituted_because_contains_sensitive_data()
117138
elif self.recursive:
118139
self.scrub_dict(v) # no-op unless v is a dict
119140
self.scrub_list(v) # no-op unless v is a list
120141

142+
for k in keys_to_remove:
143+
del d[k]
144+
121145
def scrub_request(self, event: "Event") -> None:
122146
with capture_internal_exceptions():
123147
if "request" in event:

tests/test_scrubber.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from sentry_sdk import capture_exception, capture_event, start_transaction, start_span
55
from sentry_sdk.utils import event_from_exception
6-
from sentry_sdk.scrubber import EventScrubber
6+
from sentry_sdk.scrubber import EventScrubber, DEFAULT_REMOVE_LIST
77
from tests.conftest import ApproxDict
88

99

@@ -46,17 +46,19 @@ def test_request_scrubbing(sentry_init, capture_events):
4646
"COOKIE": "[Filtered]",
4747
"authorization": "[Filtered]",
4848
"ORIGIN": "google.com",
49-
"ip_address": "[Filtered]",
5049
},
5150
"cookies": {"sessionid": "[Filtered]", "foo": "bar"},
5251
"data": {"token": "[Filtered]", "foo": "bar"},
5352
}
5453

54+
# ip_address is removed entirely (not replaced with [Filtered]) to avoid
55+
# a protocol violation, so it should not appear in the headers or _meta.
56+
assert "ip_address" not in event["request"]["headers"]
57+
5558
assert event["_meta"]["request"] == {
5659
"headers": {
5760
"COOKIE": {"": {"rem": [["!config", "s"]]}},
5861
"authorization": {"": {"rem": [["!config", "s"]]}},
59-
"ip_address": {"": {"rem": [["!config", "s"]]}},
6062
},
6163
"cookies": {"sessionid": {"": {"rem": [["!config", "s"]]}}},
6264
"data": {"token": {"": {"rem": [["!config", "s"]]}}},
@@ -248,3 +250,77 @@ def test_recursive_scrubber_does_not_override_original(sentry_init, capture_even
248250
(frame,) = frames
249251
assert data["csrf"] == "secret"
250252
assert frame["vars"]["data"]["csrf"] == "[Filtered]"
253+
254+
255+
def test_user_ip_address_removed(sentry_init, capture_events):
256+
"""ip_address in user dict must be removed entirely, not replaced with
257+
[Filtered], because [Filtered] is not a valid IP and causes a protocol
258+
violation on the server side. See GH-5701."""
259+
sentry_init()
260+
events = capture_events()
261+
262+
capture_event(
263+
{
264+
"message": "hi",
265+
"user": {"id": "1", "ip_address": "127.0.0.1"},
266+
}
267+
)
268+
269+
(event,) = events
270+
assert "ip_address" not in event["user"]
271+
assert event["user"]["id"] == "1"
272+
273+
274+
def test_user_ip_address_not_removed_when_pii_enabled(sentry_init, capture_events):
275+
"""When send_default_pii=True, ip_address should not be scrubbed at all."""
276+
sentry_init(send_default_pii=True)
277+
events = capture_events()
278+
279+
capture_event(
280+
{
281+
"message": "hi",
282+
"user": {"id": "1", "ip_address": "127.0.0.1"},
283+
}
284+
)
285+
286+
(event,) = events
287+
assert event["user"]["ip_address"] == "127.0.0.1"
288+
289+
290+
def test_custom_remove_list(sentry_init, capture_events):
291+
"""Users can configure which keys are removed rather than replaced."""
292+
sentry_init(
293+
event_scrubber=EventScrubber(remove_list=["token"]),
294+
)
295+
events = capture_events()
296+
297+
capture_event(
298+
{
299+
"message": "hi",
300+
"extra": {"token": "secret", "safe": "keep"},
301+
}
302+
)
303+
304+
(event,) = events
305+
# "token" is in the denylist AND the remove_list, so it gets deleted
306+
assert "token" not in event["extra"]
307+
assert event["extra"]["safe"] == "keep"
308+
309+
310+
def test_empty_remove_list_replaces_ip_address(sentry_init, capture_events):
311+
"""Setting remove_list=[] restores the old replace-with-[Filtered] behavior."""
312+
sentry_init(
313+
event_scrubber=EventScrubber(remove_list=[]),
314+
)
315+
events = capture_events()
316+
317+
capture_event(
318+
{
319+
"message": "hi",
320+
"user": {"id": "1", "ip_address": "127.0.0.1"},
321+
}
322+
)
323+
324+
(event,) = events
325+
# With an empty remove_list, ip_address is replaced, not removed
326+
assert event["user"]["ip_address"] == "[Filtered]"

0 commit comments

Comments
 (0)