From 9e6202279afe3e34b0d7f11d483a9b48665f8bc8 Mon Sep 17 00:00:00 2001 From: Mohil-Ahuja Date: Mon, 9 Nov 2026 15:02:37 +0530 Subject: [PATCH 1/2] fix(gql): Remove credentials and query values from the transport URL The integration copied transport.url into event["request"]["url"] unchanged, so credentials and query tokens in the URL were sent with every event, whatever send_default_pii was set to. Sanitize it with parse_url and send the filtered query in query_string. --- sentry_sdk/integrations/gql.py | 13 +++++- tests/integrations/gql/test_gql.py | 65 +++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index 37759b72d9..a057714c35 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -2,9 +2,11 @@ from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( + capture_internal_exceptions, ensure_integration_enabled, event_from_exception, has_data_collection_enabled, + parse_url, parse_version, ) @@ -99,9 +101,16 @@ def _request_info_from_transport( } try: - request_info["url"] = transport.url + url = transport.url except AttributeError: - pass + return request_info + + with capture_internal_exceptions(): + # Some transports accept a URL object as well as a string. + parsed_url = parse_url(str(url)) + request_info["url"] = parsed_url.url + if parsed_url.query: + request_info["query_string"] = parsed_url.query return request_info diff --git a/tests/integrations/gql/test_gql.py b/tests/integrations/gql/test_gql.py index dcaee5be10..464aaa68fd 100644 --- a/tests/integrations/gql/test_gql.py +++ b/tests/integrations/gql/test_gql.py @@ -1,10 +1,12 @@ +import re + import pytest import responses from gql import Client, __version__, gql from gql.transport.exceptions import TransportQueryError from gql.transport.requests import RequestsHTTPTransport -from sentry_sdk.integrations.gql import GQLIntegration +from sentry_sdk.integrations.gql import GQLIntegration, _request_info_from_transport from sentry_sdk.utils import parse_version GQL_VERSION = parse_version(__version__) @@ -273,3 +275,64 @@ def test_real_gql_request_with_error_data_collection_variables( assert event["request"]["data"].get("variables") else: assert not event["request"]["data"].get("variables") + + +@pytest.mark.parametrize("send_default_pii", (False, True)) +@responses.activate +def test_transport_url_is_sanitized(sentry_init, capture_events, send_default_pii): + sentry_init(integrations=[GQLIntegration()], send_default_pii=send_default_pii) + events = capture_events() + + responses.add( + method=responses.POST, + url=re.compile(r"https://.*api\.example\.com/graphql.*"), + json={"errors": [{"message": "some error"}]}, + status=200, + ) + transport = RequestsHTTPTransport( + url="https://user:password@api.example.com/graphql?access_token=secret&v=1#frag" + ) + client = Client(transport=transport) + + with pytest.raises(TransportQueryError): + client.execute(gql("query Example { example }")) + + (event,) = events + assert event["request"]["url"] == ( + "https://[Filtered]:[Filtered]@api.example.com/graphql" + ) + assert event["request"]["query_string"] == "access_token=[Filtered]&v=[Filtered]" + assert "password" not in str(event["request"]) + assert "secret" not in str(event["request"]) + + +def test_transport_url_without_query_has_no_query_string(): + class FakeTransport: + url = "https://api.example.com/graphql" + + assert _request_info_from_transport(FakeTransport()) == { + "method": "POST", + "url": "https://api.example.com/graphql", + } + + +def test_transport_url_object_is_sanitized(): + class FakeUrl: + def __str__(self): + return "https://user:password@api.example.com/graphql?token=secret" + + class FakeTransport: + url = FakeUrl() + + assert _request_info_from_transport(FakeTransport()) == { + "method": "POST", + "url": "https://[Filtered]:[Filtered]@api.example.com/graphql", + "query_string": "token=[Filtered]", + } + + +def test_transport_without_url(): + class FakeTransport: + pass + + assert _request_info_from_transport(FakeTransport()) == {"method": "POST"} From cfb50c64064a83f1aaad388f22c9b6125689ab5a Mon Sep 17 00:00:00 2001 From: Mohil-Ahuja Date: Mon, 9 Nov 2026 23:35:04 +0530 Subject: [PATCH 2/2] fix(gql): Respect data_collection url_query_params for the transport URL --- sentry_sdk/integrations/gql.py | 24 ++++++-- tests/integrations/gql/test_gql.py | 96 ++++++++++++++++++------------ 2 files changed, 78 insertions(+), 42 deletions(-) diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index a057714c35..a05eeaa202 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -1,4 +1,7 @@ import sentry_sdk +from sentry_sdk.data_collection import ( + _apply_data_collection_filtering_to_query_string, +) from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( @@ -38,7 +41,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Dict, Tuple, Union + from typing import Any, Dict, Optional, Tuple, Union from sentry_sdk._types import Event, EventProcessor @@ -106,11 +109,24 @@ def _request_info_from_transport( return request_info with capture_internal_exceptions(): + client_options = sentry_sdk.get_client().options + use_data_collection = has_data_collection_enabled(client_options) + # Some transports accept a URL object as well as a string. - parsed_url = parse_url(str(url)) + # Credentials are always removed from the URL. Query values are + # filtered here only when data_collection is not configured, since + # its url_query_params setting does its own filtering. + parsed_url = parse_url(str(url), sanitize=not use_data_collection) request_info["url"] = parsed_url.url - if parsed_url.query: - request_info["query_string"] = parsed_url.query + + query_string: "Optional[str]" = parsed_url.query + if use_data_collection and query_string: + query_string = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if query_string: + request_info["query_string"] = query_string return request_info diff --git a/tests/integrations/gql/test_gql.py b/tests/integrations/gql/test_gql.py index 464aaa68fd..9c0c1c94ed 100644 --- a/tests/integrations/gql/test_gql.py +++ b/tests/integrations/gql/test_gql.py @@ -6,7 +6,7 @@ from gql.transport.exceptions import TransportQueryError from gql.transport.requests import RequestsHTTPTransport -from sentry_sdk.integrations.gql import GQLIntegration, _request_info_from_transport +from sentry_sdk.integrations.gql import GQLIntegration from sentry_sdk.utils import parse_version GQL_VERSION = parse_version(__version__) @@ -277,10 +277,62 @@ def test_real_gql_request_with_error_data_collection_variables( assert not event["request"]["data"].get("variables") -@pytest.mark.parametrize("send_default_pii", (False, True)) +@pytest.mark.parametrize( + "init_kwargs,expected_query_string", + [ + pytest.param( + {}, + "access_token=[Filtered]&v=[Filtered]&page=[Filtered]", + id="no_pii_no_data_collection", + ), + pytest.param( + {"send_default_pii": True}, + "access_token=[Filtered]&v=[Filtered]&page=[Filtered]", + id="legacy_pii_on", + ), + pytest.param( + {"data_collection": {}}, + "access_token=%5BFiltered%5D&v=1&page=2", + id="data_collection_defaults", + ), + pytest.param( + {"data_collection": {"url_query_params": {"mode": "off"}}}, + None, + id="data_collection_query_params_off", + ), + pytest.param( + { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["page"]} + } + }, + "access_token=%5BFiltered%5D&v=1&page=%5BFiltered%5D", + id="data_collection_denylist", + ), + pytest.param( + { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["page"]} + } + }, + "access_token=%5BFiltered%5D&v=%5BFiltered%5D&page=2", + id="data_collection_allowlist", + ), + pytest.param( + { + "send_default_pii": True, + "data_collection": {"url_query_params": {"mode": "off"}}, + }, + None, + id="data_collection_takes_precedence_over_send_default_pii", + ), + ], +) @responses.activate -def test_transport_url_is_sanitized(sentry_init, capture_events, send_default_pii): - sentry_init(integrations=[GQLIntegration()], send_default_pii=send_default_pii) +def test_transport_url_is_sanitized( + sentry_init, capture_events, init_kwargs, expected_query_string +): + sentry_init(integrations=[GQLIntegration()], **init_kwargs) events = capture_events() responses.add( @@ -290,7 +342,7 @@ def test_transport_url_is_sanitized(sentry_init, capture_events, send_default_pi status=200, ) transport = RequestsHTTPTransport( - url="https://user:password@api.example.com/graphql?access_token=secret&v=1#frag" + url="https://user:password@api.example.com/graphql?access_token=secret&v=1&page=2#frag" ) client = Client(transport=transport) @@ -301,38 +353,6 @@ def test_transport_url_is_sanitized(sentry_init, capture_events, send_default_pi assert event["request"]["url"] == ( "https://[Filtered]:[Filtered]@api.example.com/graphql" ) - assert event["request"]["query_string"] == "access_token=[Filtered]&v=[Filtered]" + assert event["request"].get("query_string") == expected_query_string assert "password" not in str(event["request"]) assert "secret" not in str(event["request"]) - - -def test_transport_url_without_query_has_no_query_string(): - class FakeTransport: - url = "https://api.example.com/graphql" - - assert _request_info_from_transport(FakeTransport()) == { - "method": "POST", - "url": "https://api.example.com/graphql", - } - - -def test_transport_url_object_is_sanitized(): - class FakeUrl: - def __str__(self): - return "https://user:password@api.example.com/graphql?token=secret" - - class FakeTransport: - url = FakeUrl() - - assert _request_info_from_transport(FakeTransport()) == { - "method": "POST", - "url": "https://[Filtered]:[Filtered]@api.example.com/graphql", - "query_string": "token=[Filtered]", - } - - -def test_transport_without_url(): - class FakeTransport: - pass - - assert _request_info_from_transport(FakeTransport()) == {"method": "POST"}