From a19be392bdb8fc54908f3abdfde8ac0a891d129f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 10 Jul 2026 11:12:38 -0400 Subject: [PATCH 1/3] feat(integrations): apply data_collection cookie filtering to wsgi, starlette, litestar, starlite Extends the granular cookie collection controls (data_collection.cookies) to _wsgi_common, starlette, litestar, and starlite, matching the behavior already used elsewhere. Falls back to should_send_default_pii() when data_collection is not configured for cookies. HTTP "Cookie" and "set-cookie" headers will continue to be completely filtered with the "[Filtered]" value. Fixes PY-2581 Fixes #6741 --- sentry_sdk/integrations/_wsgi_common.py | 7 +- sentry_sdk/integrations/fastapi.py | 3 +- sentry_sdk/integrations/litestar.py | 12 +- sentry_sdk/integrations/starlette.py | 18 ++- sentry_sdk/integrations/starlite.py | 12 +- .../django/test_data_scrubbing.py | 112 +++++++++++++++ tests/integrations/litestar/test_litestar.py | 134 ++++++++++++++++++ .../integrations/starlette/test_starlette.py | 134 ++++++++++++++++++ tests/integrations/starlite/test_starlite.py | 134 ++++++++++++++++++ tests/integrations/tornado/test_tornado.py | 132 +++++++++++++++++ 10 files changed, 689 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index ad0ab7c734..2ee1f9b4f1 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -96,7 +96,12 @@ def extract_into_event(self, event: "Event") -> None: content_length = self.content_length() request_info = event.get("request", {}) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + request_info["cookies"] = _apply_key_value_collection_filtering( + items=dict(self.cookies()), + behaviour=client.options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) if not request_body_within_bounds(client, content_length): diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index c7b97c88b1..0b048e11ee 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -6,7 +6,6 @@ import sentry_sdk from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import StreamedSpan, get_current_span from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource from sentry_sdk.tracing_utils import has_span_streaming_enabled @@ -118,7 +117,7 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": # Extract information from request request_info = event.get("request", {}) if info: - if "cookies" in info and should_send_default_pii(): + if "cookies" in info: request_info["cookies"] = info["cookies"] if "data" in info: request_info["data"] = info["data"] diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index f0c90a7921..72c5e2847d 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -3,6 +3,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -16,6 +17,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, transaction_from_function, ) @@ -273,7 +275,8 @@ def patch_http_route_handle() -> None: async def handle_wrapper( self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" ) -> None: - if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(LitestarIntegration) is None: return await old_handle(self, scope, receive, send) sentry_scope = sentry_sdk.get_isolation_scope() @@ -312,7 +315,12 @@ async def handle_wrapper( def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + request_info["cookies"] = _apply_key_value_collection_filtering( + items=extracted_request_data["cookies"], + behaviour=client.options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: request_info["data"] = request_data diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 3f9fbf4d8e..ee094c42da 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -10,6 +10,7 @@ import sentry_sdk from sentry_sdk._types import OVER_SIZE_LIMIT_SUBSTITUTE from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -35,6 +36,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, parse_version, transaction_from_function, ) @@ -716,8 +718,15 @@ def __init__(self: "StarletteRequestExtractor", request: "Request") -> None: def extract_cookies_from_request( self: "StarletteRequestExtractor", ) -> "Optional[Dict[str, Any]]": + client_options = sentry_sdk.get_client().options cookies: "Optional[Dict[str, Any]]" = None - if should_send_default_pii(): + + if has_data_collection_enabled(client_options): + cookies = _apply_key_value_collection_filtering( + items=self.cookies(), + behaviour=client_options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): cookies = self.cookies() return cookies @@ -731,7 +740,12 @@ async def extract_request_info( with capture_internal_exceptions(): # Add cookies - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + request_info["cookies"] = _apply_key_value_collection_filtering( + items=self.cookies(), + behaviour=client.options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): request_info["cookies"] = self.cookies() # If there is no body, just return the cookies diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 1eebd37e84..85abdae837 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -2,6 +2,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.scope import should_send_default_pii @@ -10,6 +11,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, transaction_from_function, ) @@ -224,7 +226,8 @@ def patch_http_route_handle() -> None: async def handle_wrapper( self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" ) -> None: - if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(StarliteIntegration) is None: return await old_handle(self, scope, receive, send) sentry_scope = sentry_sdk.get_isolation_scope() @@ -262,7 +265,12 @@ async def handle_wrapper( def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + request_info["cookies"] = _apply_key_value_collection_filtering( + items=extracted_request_data["cookies"], + behaviour=client.options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: request_info["data"] = request_data diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py index 643a68d1b3..0cf9b7f38e 100644 --- a/tests/integrations/django/test_data_scrubbing.py +++ b/tests/integrations/django/test_data_scrubbing.py @@ -99,3 +99,115 @@ def test_scrub_django_custom_session_cookies_filtered( "csrf_secret": "[Filtered]", "foo": "bar", } + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize( + "cookies_to_set, data_collection, expected_cookies", + [ + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "off"}}, + {}, + id="off", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "denylist"}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + }, + id="denylist-default", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "denylist", "terms": ["foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "[Filtered]", + }, + id="denylist-extra-terms", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar", "bar": "baz"}, + {"cookies": {"mode": "allowlist", "terms": ["foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + "bar": "[Filtered]", + }, + id="allowlist", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar", "bar": "baz"}, + {"cookies": {"mode": "allowlist", "terms": ["sessionid", "foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + "bar": "[Filtered]", + }, + id="allowlist-cannot-override-sensitive", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + }, + id="cookies-omitted-defaults-to-denylist", + ), + ], +) +def test_data_collection_cookies( + sentry_init, + client, + capture_items, + cookies_to_set, + data_collection, + expected_cookies, +): + sentry_init( + integrations=[DjangoIntegration()], + _experiments={"data_collection": data_collection}, + ) + items = capture_items("event") + for name, value in cookies_to_set.items(): + werkzeug_set_cookie(client, "localhost", name, value) + client.get(reverse("view_exc")) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["cookies"] == expected_cookies + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +def test_data_collection_cookies_precedence_over_send_default_pii( + sentry_init, client, capture_items +): + # ``data_collection`` is the single source of truth: even with + # ``send_default_pii=False``, the configured cookie behaviour still applies. + sentry_init( + integrations=[DjangoIntegration()], + send_default_pii=False, + _experiments={"data_collection": {"cookies": {"mode": "denylist"}}}, + ) + items = capture_items("event") + werkzeug_set_cookie(client, "localhost", "sessionid", "123") + werkzeug_set_cookie(client, "localhost", "csrftoken", "456") + werkzeug_set_cookie(client, "localhost", "foo", "bar") + client.get(reverse("view_exc")) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["cookies"] == { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + } diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py index 4abb037e36..b08a0f114c 100644 --- a/tests/integrations/litestar/test_litestar.py +++ b/tests/integrations/litestar/test_litestar.py @@ -15,6 +15,7 @@ import sentry_sdk from sentry_sdk import capture_message +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.litestar import LitestarIntegration from tests.conftest import ApproxDict from tests.integrations.conftest import parametrize_test_configurable_status_codes @@ -683,6 +684,139 @@ async def __call__(self, scope, receive, send): assert "user" not in event +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + {}, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[LitestarIntegration()], + **init_kwargs, + ) + + litestar_app = litestar_app_factory() + events = capture_events() + + client = TestClient(litestar_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies + + @parametrize_test_configurable_status_codes @pytest.mark.parametrize("span_streaming", [True, False]) def test_configurable_status_codes_handler( diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 4ea57153c2..725e1cde76 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -25,6 +25,7 @@ import sentry_sdk from sentry_sdk import capture_message, get_baggage, get_traceparent +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.integrations.starlette import ( @@ -39,6 +40,12 @@ BODY_JSON = {"some": "json", "for": "testing", "nested": {"numbers": 123}} +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="photo.jpg"\r\nContent-Type: image/jpg\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace( "{{image_data}}", str(base64.b64encode(open(PICTURE, "rb").read())) ) @@ -537,6 +544,133 @@ async def test_request_info_no_pii(sentry_init, capture_events): assert transaction_event["request"]["data"] == BODY_JSON +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + {}, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +async def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration()], + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + events = capture_events() + + client = TestClient(starlette_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies + + @pytest.mark.parametrize( "url,transaction_style,expected_transaction,expected_source", [ diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index 3b6dc44131..547a7b4447 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -11,6 +11,7 @@ import sentry_sdk from sentry_sdk import capture_message +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.starlite import StarliteIntegration @@ -574,3 +575,136 @@ async def __call__(self, scope, receive, send): } else: assert "user" not in event + + +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + {}, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + **init_kwargs, + ) + + starlite_app = starlite_app_factory() + events = capture_events() + + client = TestClient(starlite_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index c5b788b289..87aef8c5c4 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -6,6 +6,7 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.tornado import TornadoIntegration @@ -110,6 +111,137 @@ def test_basic(tornado_testcase, sentry_init, capture_events): assert not sentry_sdk.get_isolation_scope()._tags +# Sent by every data-collection cookie test below. Mixes benign cookies +# (``theme``, ``lang``) with ones whose names match the data-collection +# sensitive denylist (``jwt``, ``identity``). Those two names are deliberately +# NOT in the ``EventScrubber`` denylist (which matches keys exactly), so any +# filtering we observe on them comes from the extractor's data-collection +# logic, not the always-on scrubber. +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + {}, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + tornado_testcase, sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init(integrations=[TornadoIntegration()], **init_kwargs) + events = capture_events() + client = tornado_testcase(Application([(r"/hi", CrashingHandler)])) + + response = client.fetch("/hi", headers={"Cookie": COOKIE_HEADER}) + assert response.code == 500 + + (event,) = events + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + + @pytest.mark.parametrize("send_pii", [True, False]) @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( From 620e222a6f2bc1960d1775ec1cc886d063001b59 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 10 Jul 2026 13:02:07 -0400 Subject: [PATCH 2/3] fix(integrations): omit cookies field when data_collection cookies mode is off Previously the async request extractors attached an empty cookies dict when the data_collection cookies mode was off, while sync route handlers omitted it entirely. Make all integrations consistent by not attaching the cookies field at all when filtering yields no cookies. --- sentry_sdk/integrations/_wsgi_common.py | 4 +++- sentry_sdk/integrations/litestar.py | 4 +++- sentry_sdk/integrations/starlette.py | 4 +++- sentry_sdk/integrations/starlite.py | 4 +++- tests/integrations/django/test_data_scrubbing.py | 10 ++++++++-- tests/integrations/litestar/test_litestar.py | 2 +- tests/integrations/starlette/test_starlette.py | 2 +- tests/integrations/starlite/test_starlite.py | 2 +- 8 files changed, 23 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 2ee1f9b4f1..1f1eb62c27 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -97,10 +97,12 @@ def extract_into_event(self, event: "Event") -> None: request_info = event.get("request", {}) if has_data_collection_enabled(client.options): - request_info["cookies"] = _apply_key_value_collection_filtering( + cookies = _apply_key_value_collection_filtering( items=dict(self.cookies()), behaviour=client.options["data_collection"]["cookies"], ) + if cookies: + request_info["cookies"] = cookies elif should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index 72c5e2847d..814d65faba 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -316,10 +316,12 @@ def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) if has_data_collection_enabled(client.options): - request_info["cookies"] = _apply_key_value_collection_filtering( + cookies = _apply_key_value_collection_filtering( items=extracted_request_data["cookies"], behaviour=client.options["data_collection"]["cookies"], ) + if cookies: + request_info["cookies"] = cookies elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index ee094c42da..469705cf04 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -741,10 +741,12 @@ async def extract_request_info( with capture_internal_exceptions(): # Add cookies if has_data_collection_enabled(client.options): - request_info["cookies"] = _apply_key_value_collection_filtering( + cookies = _apply_key_value_collection_filtering( items=self.cookies(), behaviour=client.options["data_collection"]["cookies"], ) + if cookies: + request_info["cookies"] = cookies elif should_send_default_pii(): request_info["cookies"] = self.cookies() diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 85abdae837..71012e69de 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -266,10 +266,12 @@ def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) if has_data_collection_enabled(client.options): - request_info["cookies"] = _apply_key_value_collection_filtering( + cookies = _apply_key_value_collection_filtering( items=extracted_request_data["cookies"], behaviour=client.options["data_collection"]["cookies"], ) + if cookies: + request_info["cookies"] = cookies elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py index 0cf9b7f38e..2f2062d11e 100644 --- a/tests/integrations/django/test_data_scrubbing.py +++ b/tests/integrations/django/test_data_scrubbing.py @@ -12,6 +12,9 @@ from django.core.urlresolvers import reverse +NO_COOKIES = object() + + @pytest.fixture def client(): return Client(application) @@ -109,7 +112,7 @@ def test_scrub_django_custom_session_cookies_filtered( pytest.param( {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, {"cookies": {"mode": "off"}}, - {}, + NO_COOKIES, id="off", ), pytest.param( @@ -184,7 +187,10 @@ def test_data_collection_cookies( client.get(reverse("view_exc")) (event,) = (item.payload for item in items if item.type == "event") - assert event["request"]["cookies"] == expected_cookies + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + else: + assert event["request"]["cookies"] == expected_cookies @pytest.mark.forked diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py index b08a0f114c..b7af8e98c8 100644 --- a/tests/integrations/litestar/test_litestar.py +++ b/tests/integrations/litestar/test_litestar.py @@ -716,7 +716,7 @@ async def __call__(self, scope, receive, send): ), pytest.param( {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, - {}, + NO_COOKIES, id="data_collection_off", ), pytest.param( diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 725e1cde76..ead289f9e0 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -570,7 +570,7 @@ async def test_request_info_no_pii(sentry_init, capture_events): ), pytest.param( {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, - {}, + NO_COOKIES, id="data_collection_off", ), pytest.param( diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index 547a7b4447..6ef5d6c53b 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -609,7 +609,7 @@ async def __call__(self, scope, receive, send): ), pytest.param( {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, - {}, + NO_COOKIES, id="data_collection_off", ), pytest.param( From ec6f52021275c8181438cc0a118703d6e767ad56 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 10 Jul 2026 13:54:25 -0400 Subject: [PATCH 3/3] update tornado test --- tests/integrations/tornado/test_tornado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index 87aef8c5c4..51c998425d 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -149,7 +149,7 @@ def test_basic(tornado_testcase, sentry_init, capture_events): ), pytest.param( {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, - {}, + NO_COOKIES, id="data_collection_off", ), pytest.param(