Skip to content

Commit 3a6cdee

Browse files
s1gr1dclaude
andcommitted
test(astro): Cover the trackClientIp fallback with userInfo enabled
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 37bb890 commit 3a6cdee

11 files changed

Lines changed: 252 additions & 55 deletions

File tree

‎dev-packages/node-integration-tests/suites/express/tracing/test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,8 @@ describe('express tracing', () => {
305305
'user-agent': expect.stringContaining(''),
306306
'content-type': 'text/plain',
307307
},
308-
data: 'some plain text',
308+
// A plain-text body has no keys the denylist can match, so it is filtered wholesale.
309+
data: '[Filtered]',
309310
},
310311
},
311312
})
@@ -330,7 +331,7 @@ describe('express tracing', () => {
330331
'user-agent': expect.stringContaining(''),
331332
'content-type': 'application/octet-stream',
332333
},
333-
data: 'some plain text in buffer',
334+
data: '[Filtered]',
334335
},
335336
},
336337
})
@@ -355,8 +356,7 @@ describe('express tracing', () => {
355356
'user-agent': expect.stringContaining(''),
356357
'content-type': 'application/octet-stream',
357358
},
358-
// This is some non-ascii string representation
359-
data: expect.any(String),
359+
data: '[Filtered]',
360360
},
361361
},
362362
})

‎dev-packages/node-integration-tests/suites/express/without-tracing/test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ describe('express without tracing', () => {
7676
'user-agent': expect.stringContaining(''),
7777
'content-type': 'text/plain',
7878
},
79-
data: 'some plain text',
79+
// A plain-text body has no keys the denylist can match, so it is filtered wholesale.
80+
data: '[Filtered]',
8081
},
8182
},
8283
})
@@ -103,7 +104,7 @@ describe('express without tracing', () => {
103104
'user-agent': expect.stringContaining(''),
104105
'content-type': 'application/octet-stream',
105106
},
106-
data: 'some plain text in buffer',
107+
data: '[Filtered]',
107108
},
108109
},
109110
})
@@ -128,8 +129,7 @@ describe('express without tracing', () => {
128129
'user-agent': expect.stringContaining(''),
129130
'content-type': 'application/octet-stream',
130131
},
131-
// This is some non-ascii string representation
132-
data: expect.any(String),
132+
data: '[Filtered]',
133133
},
134134
},
135135
})

‎packages/astro/test/server/middleware.test.ts‎

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,29 @@ describe('sentryMiddleware', () => {
4646
});
4747
const setSDKProcessingMetadataMock = vi.fn();
4848

49+
const DATA_COLLECTION_DEFAULTS = {
50+
userInfo: false,
51+
cookies: true,
52+
httpHeaders: { request: true, response: true },
53+
httpBodies: [],
54+
urlQueryParams: true,
55+
graphQL: { document: true, variables: true },
56+
genAI: { inputs: true, outputs: true },
57+
databaseQueryData: true,
58+
stackFrameVariables: true,
59+
frameContextLines: 5,
60+
};
61+
62+
function mockClientWith(dataCollection: Partial<typeof DATA_COLLECTION_DEFAULTS>): void {
63+
vi.spyOn(SentryNode, 'getClient').mockImplementation(
64+
() =>
65+
({
66+
getOptions: () => ({}),
67+
getDataCollectionOptions: () => ({ ...DATA_COLLECTION_DEFAULTS, ...dataCollection }),
68+
}) as unknown as Client,
69+
);
70+
}
71+
4972
beforeEach(() => {
5073
vi.spyOn(SentryNode, 'getCurrentScope').mockImplementation(() => {
5174
return {
@@ -56,24 +79,7 @@ describe('sentryMiddleware', () => {
5679
} as any;
5780
});
5881
vi.spyOn(SentryNode, 'getActiveSpan').mockImplementation(getSpanMock);
59-
vi.spyOn(SentryNode, 'getClient').mockImplementation(
60-
() =>
61-
({
62-
getOptions: () => ({}),
63-
getDataCollectionOptions: () => ({
64-
userInfo: false,
65-
cookies: true,
66-
httpHeaders: { request: true, response: true },
67-
httpBodies: [],
68-
urlQueryParams: true,
69-
graphQL: { document: true, variables: true },
70-
genAI: { inputs: true, outputs: true },
71-
databaseQueryData: true,
72-
stackFrameVariables: true,
73-
frameContextLines: 5,
74-
}),
75-
}) as unknown as Client,
76-
);
82+
mockClientWith({ userInfo: false });
7783
vi.spyOn(SentryNode, 'getTraceMetaTags').mockImplementation(
7884
() => `
7985
<meta name="sentry-trace" content="123">
@@ -308,8 +314,22 @@ describe('sentryMiddleware', () => {
308314
});
309315
});
310316

311-
it('follows `dataCollection.userInfo` when `trackClientIp` is not set', async () => {
312-
// The shared client mock resolves `userInfo` to `false`.
317+
it('attaches the client IP when `trackClientIp` is unset and `dataCollection.userInfo` is on', async () => {
318+
mockClientWith({ userInfo: true });
319+
const middleware = handleRequest();
320+
const ctx = {
321+
...DYNAMIC_REQUEST_CONTEXT,
322+
};
323+
324+
// @ts-expect-error, a partial ctx object is fine here
325+
await middleware(ctx, async () => {
326+
expect(SentryCore.getIsolationScope().getScopeData().user?.ip_address).toBe('192.168.0.1');
327+
return nextResult;
328+
});
329+
});
330+
331+
it('does not attach a client IP when `trackClientIp` is unset and `dataCollection.userInfo` is off', async () => {
332+
mockClientWith({ userInfo: false });
313333
const middleware = handleRequest();
314334
const ctx = {
315335
...DYNAMIC_REQUEST_CONTEXT,
@@ -322,7 +342,8 @@ describe('sentryMiddleware', () => {
322342
});
323343
});
324344

325-
it('does not attach a client IP if `trackClientIp=false`', async () => {
345+
it('lets `trackClientIp=false` win over `dataCollection.userInfo`', async () => {
346+
mockClientWith({ userInfo: true });
326347
const middleware = handleRequest({ trackClientIp: false });
327348
const ctx = {
328349
...DYNAMIC_REQUEST_CONTEXT,

‎packages/browser/src/integrations/graphqlClient.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ interface GraphQLOperation {
5252

5353
const INTEGRATION_NAME = 'GraphQLClient' as const;
5454

55+
// GraphQL literal argument values can carry user data, so they are replaced before the document is
56+
// attached — matching what the server-side GraphQL instrumentation does with the parsed AST.
57+
// Only Int/Float/String/BlockString literals are replaced; names, enums and booleans are structural.
58+
const GRAPHQL_LITERAL_RE = /"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g;
59+
60+
/** Replaces every literal argument value in a raw GraphQL document with a placeholder. */
61+
export function _redactGraphqlDocument(document: string): string {
62+
return document.replace(GRAPHQL_LITERAL_RE, match => (match.startsWith('"') ? '"*"' : '*'));
63+
}
64+
5565
const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
5666
return {
5767
name: INTEGRATION_NAME,
@@ -103,7 +113,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption
103113

104114
// Handle standard requests - capture the query document when enabled via dataCollection (default true)
105115
if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) {
106-
span.setAttribute(GRAPHQL_DOCUMENT, graphqlBody.query);
116+
span.setAttribute(GRAPHQL_DOCUMENT, _redactGraphqlDocument(graphqlBody.query));
107117
}
108118

109119
// Handle persisted operations - capture hash for debugging
@@ -140,7 +150,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient
140150
data['graphql.operation'] = operationInfo;
141151

142152
if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) {
143-
data[GRAPHQL_DOCUMENT] = graphqlBody.query;
153+
data[GRAPHQL_DOCUMENT] = _redactGraphqlDocument(graphqlBody.query);
144154
}
145155

146156
if (isPersistedRequest(graphqlBody)) {

‎packages/browser/test/integrations/graphqlClient.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,33 @@ import { URL_FULL } from '@sentry/conventions/attributes';
1010
import { describe, expect, test } from 'vitest';
1111
import {
1212
_getGraphQLOperation,
13+
_redactGraphqlDocument,
1314
getGraphQLRequestPayload,
1415
getRequestPayloadXhrOrFetch,
1516
graphqlClientIntegration,
1617
parseGraphQLQuery,
1718
} from '../../src/integrations/graphqlClient';
1819

20+
describe('_redactGraphqlDocument', () => {
21+
test('replaces string and numeric literal arguments', () => {
22+
expect(_redactGraphqlDocument('query { user(email: "jane@example.com", age: 42) { name } }')).toBe(
23+
'query { user(email: "*", age: *) { name } }',
24+
);
25+
});
26+
27+
test('replaces block string literals', () => {
28+
expect(_redactGraphqlDocument('mutation { post(body: """secret\nlines""") { id } }')).toBe(
29+
'mutation { post(body: "*") { id } }',
30+
);
31+
});
32+
33+
test('leaves documents without literals untouched', () => {
34+
const document = 'query Test($id: ID!) {\n people {\n name\n }\n}';
35+
36+
expect(_redactGraphqlDocument(document)).toBe(document);
37+
});
38+
});
39+
1940
describe('GraphqlClient', () => {
2041
describe('parseGraphQLQuery', () => {
2142
const queryOne = `query Test {

‎packages/cloudflare/test/request.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ describe('withSentry', () => {
343343
request: new Request('https://example.com', {
344344
method: 'POST',
345345
headers: { 'content-type': 'application/json' },
346-
body: JSON.stringify({ key: 'value' }),
346+
body: JSON.stringify({ colour: 'blue' }),
347347
}),
348348
context,
349349
},
@@ -353,7 +353,7 @@ describe('withSentry', () => {
353353
},
354354
);
355355

356-
expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' }));
356+
expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ colour: 'blue' }));
357357
});
358358

359359
test('does not capture cookies when dataCollection.cookies is disabled', async () => {

‎packages/core/src/integrations/http/patch-request-to-capture-body.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Scope } from '../../scope';
22
import { debug } from '../../utils/debug-logger';
33
import { DEBUG_BUILD } from '../../debug-build';
44
import type { HttpIncomingMessage } from './types';
5+
import { filterCollectedHttpBodyString } from '../../utils/data-collection/filterHttpBody';
56
import { getMaxBodyByteLength, type MaxRequestBodySize } from '../../utils/request';
67

78
/**
@@ -92,7 +93,9 @@ export function patchRequestToCaptureBody(
9293

9394
req.on('end', () => {
9495
try {
95-
const body = Buffer.concat(chunks).toString('utf-8');
96+
// Filtered before truncation: truncating first would make a parseable body unparseable, and
97+
// an unparseable body has to be dropped wholesale.
98+
const body = filterCollectedHttpBodyString(Buffer.concat(chunks).toString('utf-8'));
9699
if (body) {
97100
// Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long
98101
const bodyByteLength = Buffer.byteLength(body, 'utf-8');
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { isPlainObject } from '../is';
2+
import { FILTERED_VALUE } from './filtering-snippets';
3+
import { shouldFilterDataKey } from './filterKeyValueData';
4+
import { filterQueryParams } from './filterQueryParams';
5+
6+
/** `key=value&key2=value2` — the only non-JSON body shape with keys the denylist can match. */
7+
const FORM_BODY_RE = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/;
8+
9+
/**
10+
* Scrubs an HTTP body the SDK collected itself, before it is attached as `request.data` /
11+
* `http.request.body.data`.
12+
*
13+
* A body that can be parsed into key-value pairs keeps its shape, with values for keys matching the
14+
* sensitive denylist replaced. A body that cannot be parsed has no keys to match against, so the
15+
* spec requires the whole value to be replaced rather than sent raw.
16+
*
17+
* Bodies a user attaches themselves never pass through here — `dataCollection` only gates data the
18+
* SDK collects automatically.
19+
*/
20+
export function filterCollectedHttpBody(body: unknown): unknown {
21+
if (body == null) {
22+
return body;
23+
}
24+
25+
if (typeof body === 'string') {
26+
return filterCollectedHttpBodyString(body);
27+
}
28+
29+
// Anything that is not a key-value structure (a `Buffer`, a stream, a number) has no keys to match
30+
// against, so it is filtered wholesale.
31+
return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body) : FILTERED_VALUE;
32+
}
33+
34+
/**
35+
* String-only variant of {@link filterCollectedHttpBody}, for body capture sites that filter before
36+
* truncating: truncating first would make a parseable body unparseable, forcing it to be dropped.
37+
*/
38+
export function filterCollectedHttpBodyString(body: string): string {
39+
try {
40+
const json: unknown = JSON.parse(body);
41+
// A bare JSON scalar (`"hi"`, `42`) has no keys to match against, so it counts as unparseable.
42+
if (typeof json === 'object' && json !== null) {
43+
return JSON.stringify(filterBodyValue(json));
44+
}
45+
} catch {
46+
// Not JSON — fall through to the form-encoded attempt below.
47+
}
48+
49+
// Reuses the query-param filter so the body's original encoding is preserved byte-for-byte.
50+
return (FORM_BODY_RE.test(body) && filterQueryParams(body, true)) || FILTERED_VALUE;
51+
}
52+
53+
function filterBodyValue(value: unknown): unknown {
54+
if (Array.isArray(value)) {
55+
return value.map(filterBodyValue);
56+
}
57+
58+
if (!isPlainObject(value)) {
59+
return value;
60+
}
61+
62+
const result: Record<string, unknown> = {};
63+
for (const [key, nested] of Object.entries(value)) {
64+
result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested);
65+
}
66+
return result;
67+
}

‎packages/core/src/utils/request.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { RequestEventData } from '../types/request';
77
import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi';
88
import { debug } from './debug-logger';
99
import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets';
10+
import { filterCollectedHttpBody, filterCollectedHttpBodyString } from './data-collection/filterHttpBody';
1011
import { filterKeyValueData } from './data-collection/filterKeyValueData';
1112
import { safeUnref } from './timer';
1213
import { getUrlQuery } from './url';
@@ -158,17 +159,21 @@ export async function captureBodyFromWinterCGRequest(
158159
safeUnref(setTimeout(() => resolve(null), 2000));
159160
});
160161

161-
const body = await Promise.race([bodyPromise, timeoutPromise]);
162+
const rawBody = await Promise.race([bodyPromise, timeoutPromise]);
162163

163-
if (body === null) {
164+
if (rawBody === null) {
164165
DEBUG_BUILD && debug.log('Timeout reading request body');
165166
return;
166167
}
167168

168-
if (!body) {
169+
if (!rawBody) {
169170
return;
170171
}
171172

173+
// Filtered before truncation: truncating first would make a parseable body unparseable, and an
174+
// unparseable body has to be dropped wholesale.
175+
const body = filterCollectedHttpBodyString(rawBody);
176+
172177
// Using TextEncoder to get byte length for UTF-8 strings
173178
const encoder = new TextEncoder();
174179
const bytes = encoder.encode(body);
@@ -227,7 +232,8 @@ export function httpRequestToRequestData(request: {
227232

228233
// This is non-standard, but may be sometimes set
229234
// It may be overwritten later by our own body handling
230-
const data = (request as PolymorphicRequest).body || undefined;
235+
const body = (request as PolymorphicRequest).body || undefined;
236+
const data = body === undefined ? undefined : filterCollectedHttpBody(body);
231237

232238
// This is non-standard, but may be set on e.g. Next.js or Express requests
233239
const cookies = (request as PolymorphicRequest).cookies;

0 commit comments

Comments
 (0)