diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 89aa5abe3411..eda1603c809d 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -11,6 +11,7 @@ import { parseCookie } from '../utils/cookie'; import { SENSITIVE_COOKIE_NAME_SNIPPETS } from '../utils/data-collection/filtering-snippets'; import { filterKeyValueData } from '../utils/data-collection/filterKeyValueData'; import { filterQueryParams } from '../utils/data-collection/filterQueryParams'; +import { filterUrlQuery } from '../utils/data-collection/filterUrlQuery'; import { httpHeadersToSpanAttributes } from '../utils/request'; import { getUrlQuery } from '../utils/url'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; @@ -138,6 +139,9 @@ function addNormalizedRequestDataToEvent( if (requestData.query_string) { requestData.query_string = normalizeAndFilterQueryString(requestData.query_string, dataCollection.urlQueryParams); } + if (requestData.url) { + requestData.url = filterUrlQuery(requestData.url, dataCollection.urlQueryParams); + } event.request = { ...event.request, @@ -166,7 +170,7 @@ function addNormalizedRequestDataToSpan( const attributes: Record = {}; if (requestData.url) { - attributes[URL_FULL] = requestData.url; + attributes[URL_FULL] = filterUrlQuery(requestData.url, dataCollection.urlQueryParams); } if (requestData.method) { diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index 2810277fe90f..63282281abf8 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -49,6 +49,42 @@ function richNormalizedRequest() { } describe('requestDataIntegration', () => { + // `event.request.url` carries the same query string as `request.query_string`, so it has to respect + // `dataCollection.urlQueryParams` too. + describe('event.request.url query params', () => { + function processWith(urlQueryParams?: DataCollection['urlQueryParams']): Event { + const integration = requestDataIntegration(); + const event = baseEvent({ + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/reset?token=secret&id=1', + query_string: 'token=secret&id=1', + }, + }, + }); + + integration.processEvent?.(event, {}, mockClient({ userInfo: false, urlQueryParams })); + + return event; + } + + it('filters sensitive params by default', () => { + expect(processWith().request?.url).toBe('https://example.com/reset?token=[Filtered]&id=1'); + }); + + it('strips the query entirely when collection is off', () => { + const event = processWith(false); + + expect(event.request?.url).toBe('https://example.com/reset'); + expect(event.request?.query_string).toBeUndefined(); + }); + + it('honors allowList mode', () => { + expect(processWith({ allow: ['id'] }).request?.url).toBe('https://example.com/reset?token=[Filtered]&id=1'); + }); + }); + describe('IP-related headers on event.request', () => { it('removes known IP headers from event.request.headers when userInfo is false', () => { const integration = requestDataIntegration(); @@ -974,6 +1010,24 @@ describe('requestDataIntegration processSegmentSpan', () => { }); }); + it('filters sensitive query params in `url.full` on the segment span', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com/api/users?token=secret&page=1', + method: 'GET', + query_string: 'token=secret&page=1', + }); + + integration.processSegmentSpan!(span, mockClient({ userInfo: false })); + + expect(span.attributes).toMatchObject({ + 'url.full': 'https://example.com/api/users?token=[Filtered]&page=1', + 'url.query': 'token=[Filtered]&page=1', + }); + }); + it('handles query_string in object format', () => { const integration = requestDataIntegration(); const span = makeSpan(); diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index b7edc7624651..635ddac3ec03 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -212,7 +212,7 @@ export function createSentryClientInstrumentation( navigationSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } captureInstrumentationError(result, captureErrors, 'react_router.navigate', { - [URL_FULL]: toPath, + [URL_FULL]: filterCollectedUrl(toPath), }); } return; diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts index 284f66a72555..f74d180a1a63 100644 --- a/packages/react-router/test/client/createClientInstrumentation.test.ts +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -300,6 +300,34 @@ describe('createSentryClientInstrumentation', () => { ); }); + // `navigate('/x?token=y')` is app-supplied, so the query has to go through `dataCollection.urlQueryParams`. + it('filters sensitive query params in the `url.full` reported for a failed navigate', async () => { + const mockError = new Error('Navigate failed'); + const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.getClient as any).mockReturnValue({}); + (globalThis as any).location = { + href: 'https://example.com/home', + origin: 'https://example.com', + pathname: '/home', + }; + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.navigate(mockCallNavigate, { currentUrl: '/home', to: '/search?token=secret&page=1' }); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.navigate', + handled: false, + data: { 'url.full': '/search?token=[Filtered]&page=1' }, + }, + }); + }); + it('should capture errors when captureErrors is true (default)', async () => { const mockError = new Error('Test error'); // React Router returns an error result, not a rejection