Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ describe('express tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'text/plain',
},
data: 'some plain text',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// A plain-text body has no keys the denylist can match, so it is filtered completely.
data: '[Filtered]',
},
},
})
Expand All @@ -330,7 +331,7 @@ describe('express tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'application/octet-stream',
},
data: 'some plain text in buffer',
data: '[Filtered]',
},
},
})
Expand All @@ -355,8 +356,7 @@ describe('express tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'application/octet-stream',
},
// This is some non-ascii string representation
data: expect.any(String),
data: '[Filtered]',
},
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ describe('express without tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'text/plain',
},
data: 'some plain text',
// A plain-text body has no keys the denylist can match, so it is filtered completely.
data: '[Filtered]',
},
},
})
Expand All @@ -103,7 +104,7 @@ describe('express without tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'application/octet-stream',
},
data: 'some plain text in buffer',
data: '[Filtered]',
},
},
})
Expand All @@ -128,8 +129,7 @@ describe('express without tracing', () => {
'user-agent': expect.stringContaining(''),
'content-type': 'application/octet-stream',
},
// This is some non-ascii string representation
data: expect.any(String),
data: '[Filtered]',
},
},
})
Expand Down
13 changes: 11 additions & 2 deletions packages/browser/src/integrations/graphqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ interface GraphQLOperation {

const INTEGRATION_NAME = 'GraphQLClient' as const;

// Matches the Int, Float, String, and BlockString literals in a document, the same set the
// server-side GraphQL integration redacts from the parsed AST. Names, enums, and booleans stay.
const GRAPHQL_LITERAL_RE = /"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g;

/** Replaces every literal value in a raw GraphQL document, since literals can carry user data. */
export function _redactGraphqlDocument(document: string): string {
return document.replace(GRAPHQL_LITERAL_RE, match => (match.startsWith('"') ? '"*"' : '*'));
}

const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
return {
name: INTEGRATION_NAME,
Expand Down Expand Up @@ -103,7 +112,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption

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

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

if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) {
data[GRAPHQL_DOCUMENT] = graphqlBody.query;
data[GRAPHQL_DOCUMENT] = _redactGraphqlDocument(graphqlBody.query);
}

if (isPersistedRequest(graphqlBody)) {
Expand Down
21 changes: 21 additions & 0 deletions packages/browser/test/integrations/graphqlClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,33 @@ import { URL_FULL } from '@sentry/conventions/attributes';
import { describe, expect, test } from 'vitest';
import {
_getGraphQLOperation,
_redactGraphqlDocument,
getGraphQLRequestPayload,
getRequestPayloadXhrOrFetch,
graphqlClientIntegration,
parseGraphQLQuery,
} from '../../src/integrations/graphqlClient';

describe('_redactGraphqlDocument', () => {
test('replaces string and numeric literal arguments', () => {
expect(_redactGraphqlDocument('query { user(email: "jane@example.com", age: 42) { name } }')).toBe(
'query { user(email: "*", age: *) { name } }',
);
});

test('replaces block string literals', () => {
expect(_redactGraphqlDocument('mutation { post(body: """secret\nlines""") { id } }')).toBe(
'mutation { post(body: "*") { id } }',
);
});

test('leaves documents without literals untouched', () => {
const document = 'query Test($id: ID!) {\n people {\n name\n }\n}';

expect(_redactGraphqlDocument(document)).toBe(document);
});
});

describe('GraphqlClient', () => {
describe('parseGraphQLQuery', () => {
const queryOne = `query Test {
Expand Down
4 changes: 2 additions & 2 deletions packages/cloudflare/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ describe('withSentry', () => {
request: new Request('https://example.com', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
body: JSON.stringify({ colour: 'blue' }),
}),
context,
},
Expand All @@ -353,7 +353,7 @@ describe('withSentry', () => {
},
);

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

test('does not capture cookies when dataCollection.cookies is disabled', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Scope } from '../../scope';
import { debug } from '../../utils/debug-logger';
import { DEBUG_BUILD } from '../../debug-build';
import type { HttpIncomingMessage } from './types';
import { FILTERED_VALUE } from '../../utils/data-collection/filtering-snippets';
import { filterCollectedHttpBodyString } from '../../utils/data-collection/filterHttpBody';
import { getMaxBodyByteLength, type MaxRequestBodySize } from '../../utils/request';

/**
Expand All @@ -19,6 +21,7 @@ export function patchRequestToCaptureBody(
): void {
let bodyByteLength = 0;
const chunks: Buffer[] = [];
let chunksDropped = false;

DEBUG_BUILD && debug.log(integrationName, 'Patching request.on');

Expand Down Expand Up @@ -48,11 +51,13 @@ export function patchRequestToCaptureBody(
if (bodyByteLength < maxBodySize) {
chunks.push(bufferifiedChunk);
bodyByteLength += bufferifiedChunk.byteLength;
} else if (DEBUG_BUILD) {
debug.log(
integrationName,
`Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`,
);
} else {
chunksDropped = true;
DEBUG_BUILD &&
debug.log(
integrationName,
`Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`,
);
}
} catch {
DEBUG_BUILD && debug.error(integrationName, 'Encountered error while storing body chunk.');
Expand Down Expand Up @@ -92,7 +97,10 @@ export function patchRequestToCaptureBody(

req.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf-8');
const rawBody = Buffer.concat(chunks).toString('utf-8');
// The filter runs before truncation, because a truncated JSON body no longer parses.
// A capped stream is already incomplete, so its prefix is filtered without a parse attempt.
const body = rawBody && (chunksDropped ? FILTERED_VALUE : filterCollectedHttpBodyString(rawBody));
if (body) {
// Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long
const bodyByteLength = Buffer.byteLength(body, 'utf-8');
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/utils/data-collection/filterHttpBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { isPlainObject } from '../is';
import { FILTERED_VALUE } from './filtering-snippets';
import { shouldFilterDataKey } from './filterKeyValueData';
import { filterQueryParams } from './filterQueryParams';

/** Matches `key=value&key2=value2` bodies, the only non-JSON shape whose keys the denylist can check. */
const FORM_BODY_RE = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/;

/**
* Scrubs an HTTP body the SDK collected itself, before it becomes `request.data` or
* `http.request.body.data`. A parseable body keeps its shape, and only the values of sensitive keys
* are replaced. An unparseable body has no keys to match, so the whole value becomes `[Filtered]`.
*/
export function filterCollectedHttpBody(body: unknown): unknown {
if (body == null) {
return body;
}

if (typeof body === 'string') {
return filterCollectedHttpBodyString(body);
}

// A `Buffer`, a stream, or a number has no keys to match, so the whole value is filtered.
return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body) : FILTERED_VALUE;
}

/**
* String-only variant of {@link filterCollectedHttpBody}. Capture sites call this before they
* truncate, because a truncated JSON body no longer parses and would be dropped wholesale.
*/
export function filterCollectedHttpBodyString(body: string): string {
if (!body) {
return body;
}

try {
const json: unknown = JSON.parse(body);
// A bare JSON scalar (`"hi"`, `42`) has no keys to match against, so it counts as unparseable.
if (typeof json === 'object' && json !== null) {
return JSON.stringify(filterBodyValue(json));
}
} catch {
// Not JSON. The form-encoded attempt below runs instead.
}

// The query-param filter keeps the body's original encoding byte-for-byte.
return (FORM_BODY_RE.test(body) && filterQueryParams(body, true)) || FILTERED_VALUE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form heuristic leaks unstructured bodies

Medium Severity

After JSON.parse fails to yield an object or array, any string matching FORM_BODY_RE is treated as form data and returned with keys intact. Unstructured bodies that merely contain = (base64 padding, JSON string scalars, prose) therefore keep their payload, and because the denylist only replaces values the bulk of that data is preserved as the key.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit c943d35. Configure here.

}

function filterBodyValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(filterBodyValue);
}

if (!isPlainObject(value)) {
return value;
}

const result: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested);
}
return result;
}
12 changes: 8 additions & 4 deletions packages/core/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { RequestEventData } from '../types/request';
import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi';
import { debug } from './debug-logger';
import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets';
import { filterCollectedHttpBody, filterCollectedHttpBodyString } from './data-collection/filterHttpBody';
import { filterKeyValueData } from './data-collection/filterKeyValueData';
import { safeUnref } from './timer';
import { getUrlQuery } from './url';
Expand Down Expand Up @@ -158,17 +159,20 @@ export async function captureBodyFromWinterCGRequest(
safeUnref(setTimeout(() => resolve(null), 2000));
});

const body = await Promise.race([bodyPromise, timeoutPromise]);
const rawBody = await Promise.race([bodyPromise, timeoutPromise]);

if (body === null) {
if (rawBody === null) {
DEBUG_BUILD && debug.log('Timeout reading request body');
return;
}

if (!body) {
if (!rawBody) {
return;
}

// The filter runs before truncation, because a truncated JSON body no longer parses.
const body = filterCollectedHttpBodyString(rawBody);

// Using TextEncoder to get byte length for UTF-8 strings
const encoder = new TextEncoder();
const bytes = encoder.encode(body);
Expand Down Expand Up @@ -227,7 +231,7 @@ export function httpRequestToRequestData(request: {

// This is non-standard, but may be sometimes set
// It may be overwritten later by our own body handling
const data = (request as PolymorphicRequest).body || undefined;
const data = filterCollectedHttpBody((request as PolymorphicRequest).body || undefined);

// This is non-standard, but may be set on e.g. Next.js or Express requests
const cookies = (request as PolymorphicRequest).cookies;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import { patchRequestToCaptureBody } from '../../../../src/integrations/http/patch-request-to-capture-body';
import type { HttpIncomingMessage } from '../../../../src/integrations/http/types';
import type { Scope } from '../../../../src/scope';

function makeFakeRequest(): { req: HttpIncomingMessage; emit: (event: string, ...args: unknown[]) => void } {
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req: any = {
on(event: string, cb: (...args: unknown[]) => void) {
(listeners[event] ??= []).push(cb);
return req;
},
off(event: string, cb: (...args: unknown[]) => void) {
listeners[event] = (listeners[event] ?? []).filter(listener => listener !== cb);
return req;
},
};
req.addListener = req.on;
req.removeListener = req.off;

return {
req: req as HttpIncomingMessage,
emit: (event, ...args) => (listeners[event] ?? []).slice().forEach(cb => cb(...args)),
};
}

function capture(chunks: string[]): ReturnType<typeof vi.fn> {
const setSDKProcessingMetadata = vi.fn();
const scope = { setSDKProcessingMetadata } as unknown as Scope;
const { req, emit } = makeFakeRequest();

patchRequestToCaptureBody(req, scope, 'small', 'test');
// The patch only records chunks when the app itself consumes the body.
req.on('data', () => {});
chunks.forEach(chunk => emit('data', Buffer.from(chunk)));
emit('end');

return setSDKProcessingMetadata;
}

function expectCapturedBody(spy: ReturnType<typeof vi.fn>, data: unknown): void {
expect(spy).toHaveBeenCalledWith({ normalizedRequest: { data } });
}

describe('patchRequestToCaptureBody', () => {
it('filters sensitive keys in a complete JSON body', () => {
expectCapturedBody(capture(['{"colour":"blue",', '"token":"abc"}']), '{"colour":"blue","token":"[Filtered]"}');
});

it('keeps the filter-then-truncate order for a body that overshoots the limit in its final chunk', () => {
// 9 bytes of prefix + 988 kept characters + `...` = the 1000-byte `small` limit.
expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`]), expect.stringMatching(/^\{"note":"x{988}\.\.\.$/));
});

it('filters a capped stream wholesale, since the dropped chunks make it unparseable', () => {
expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`, '{"more":"data"}']), '[Filtered]');
});

it('filters a body that cannot be parsed into key-value pairs', () => {
expectCapturedBody(capture(['plain text body']), '[Filtered]');
});

it('attaches nothing for an empty body', () => {
expect(capture([])).not.toHaveBeenCalled();
});
});
Loading
Loading