diff --git a/.size-limit.js b/.size-limit.js index 3b96129de159..948bcdfdeb78 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -504,7 +504,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '502 KiB', + limit: '503 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts index 68649db65b63..a8513257b654 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/test.ts @@ -43,7 +43,7 @@ it('Captures form-urlencoded request body', async ({ signal }) => { headers: expect.any(Object), method: 'POST', url: expect.stringContaining('/post-form'), - data: 'username=test&password=secret', + data: 'username=test&password=[Filtered]', }, }, // Raw URL span (source `url`), so the TwP DSC omits the span name. diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 643ced098f0f..f5b649864ae0 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -52,6 +52,17 @@ 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. +// The block-string branch consumes escaped `\"""` as a unit, so the lazy match cannot end on an +// escaped delimiter and leak the remainder of the block. +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, @@ -103,7 +114,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 @@ -140,7 +151,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)) { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 92dabd9adf53..6fee9db6beb6 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import type { Client } from '@sentry/core'; +import type { Breadcrumb, Client } from '@sentry/core'; import { SentrySpan, spanToJSON } from '@sentry/core'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; @@ -10,12 +10,36 @@ 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: """a \\""" b""") { id } }')).toBe( + 'mutation { post(body: "*") { id } }', + ); + 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 { @@ -376,6 +400,33 @@ describe('GraphqlClient', () => { expect(json.attributes['graphql.operation.type']).toBe('query'); }); + test('redacts literals in the captured document', () => { + const handler = setupHandler([/\/graphql$/]); + const span = new SentrySpan({ + name: 'POST http://localhost:4000/graphql', + op: 'http.client', + attributes: { + 'http.method': 'POST', + [URL_FULL]: 'http://localhost:4000/graphql', + url: 'http://localhost:4000/graphql', + }, + }); + + handler( + span, + makeFetchHint('http://localhost:4000/graphql', { + query: 'query GetUser { user(email: "jane@example.com", age: 42) { name } }', + operationName: 'GetUser', + variables: {}, + extensions: {}, + }), + ); + + expect(spanToJSON(span).attributes['graphql.document']).toBe( + 'query GetUser { user(email: "*", age: *) { name } }', + ); + }); + test('keeps the low-cardinality span name with span streaming enabled', () => { const handler = setupHandler([/\/graphql$/], true, 'stream'); const span = new SentrySpan({ @@ -512,4 +563,53 @@ describe('GraphqlClient', () => { expect(json.attributes['graphql.document']).toBeUndefined(); }); }); + + describe('beforeOutgoingRequestBreadcrumb handler', () => { + test('redacts literals in the captured document', () => { + let capturedListener: ((breadcrumb: Breadcrumb, handlerData: FetchHint | XhrHint) => void) | undefined; + const mockClient = { + on: (eventName: string, cb: (breadcrumb: Breadcrumb, handlerData: FetchHint | XhrHint) => void) => { + if (eventName === 'beforeOutgoingRequestBreadcrumb') { + capturedListener = cb; + } + }, + getOptions: () => ({}), + getDataCollectionOptions: () => ({ graphQL: { document: true, variables: true } }), + } as unknown as Client; + + const integration = graphqlClientIntegration({ endpoints: [/\/graphql$/] }); + integration.setup?.(mockClient); + + if (!capturedListener) { + throw new Error('beforeOutgoingRequestBreadcrumb listener was not registered'); + } + + const breadcrumb: Breadcrumb = { + category: 'fetch', + type: 'http', + data: { url: 'http://localhost:4000/graphql', method: 'POST' }, + }; + + capturedListener(breadcrumb, { + input: [ + 'http://localhost:4000/graphql', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'query GetUser { user(email: "jane@example.com", age: 42) { name } }', + operationName: 'GetUser', + variables: {}, + extensions: {}, + }), + }, + ], + response: new Response(null, { status: 200 }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1, + }); + + expect(breadcrumb.data?.['graphql.document']).toBe('query GetUser { user(email: "*", age: *) { name } }'); + }); + }); }); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index baf9282f9e15..6ab6f7913d2f 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -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, }, @@ -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 () => { diff --git a/packages/core/src/integrations/http/patch-request-to-capture-body.ts b/packages/core/src/integrations/http/patch-request-to-capture-body.ts index 774d84eba7db..039c8d0c99ad 100644 --- a/packages/core/src/integrations/http/patch-request-to-capture-body.ts +++ b/packages/core/src/integrations/http/patch-request-to-capture-body.ts @@ -2,6 +2,7 @@ import type { Scope } from '../../scope'; import { debug } from '../../utils/debug-logger'; import { DEBUG_BUILD } from '../../debug-build'; import type { HttpIncomingMessage } from './types'; +import { filterCollectedHttpBodyString } from '../../utils/data-collection/filterHttpBody'; import { getMaxBodyByteLength, type MaxRequestBodySize } from '../../utils/request'; /** @@ -92,7 +93,8 @@ export function patchRequestToCaptureBody( req.on('end', () => { try { - const body = Buffer.concat(chunks).toString('utf-8'); + // The filter runs before truncation, because a truncated JSON body no longer parses. + const body = filterCollectedHttpBodyString(Buffer.concat(chunks).toString('utf-8')); 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'); diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 55e428f60458..8dad5536a913 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -11,6 +11,7 @@ import { getClient, withIsolationScope } from './currentScopes'; import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { startSpanManual } from './tracing/trace'; +import { filterCollectedHttpBody } from './utils/data-collection/filterHttpBody'; import { normalize } from './utils/normalize'; import { setNormalizationDepthOverrideHint } from './utils/normalizationHints'; @@ -74,15 +75,17 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { ? options.attachRpcInput : dataCollection?.httpBodies.includes('incomingRequest') ) { + // Filtering runs after normalization so class instances become plain objects the + // key-value filter can walk instead of being redacted as a whole. if (rawInput !== undefined) { - trpcContext.input = normalize(rawInput); + trpcContext.input = filterCollectedHttpBody(normalize(rawInput)); } if (getRawInput !== undefined && typeof getRawInput === 'function') { try { const rawRes = await getRawInput(); - trpcContext.input = normalize(rawRes); + trpcContext.input = filterCollectedHttpBody(normalize(rawRes)); } catch { // noop } diff --git a/packages/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts new file mode 100644 index 000000000000..9da018a53ab6 --- /dev/null +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -0,0 +1,83 @@ +import { isPlainObject } from '../is'; +import { FILTERED_VALUE } from './filtering-snippets'; +import { shouldFilterDataKey } from './filterKeyValueData'; +import { filterQueryParams } from './filterQueryParams'; + +/** + * One `&`-separated form segment: empty, a bare key, or `key=value`. Keys are limited to the + * characters `application/x-www-form-urlencoded` encoding produces and raw whitespace disqualifies + * (encoded forms write spaces as `+` or `%20`), so XML, multipart, prose, and URLs never count as + * a pseudo-form that the filter would then rewrite. + */ +const FORM_SEGMENT_RE = /^(?:[\w%.*+-]+(?:=[^&\s]*)?)?$/; + +/** + * A form body is `&`-separated `key=value` pairs, the only non-JSON shape whose keys the denylist + * can check. Valueless keys, empty segments, and a trailing `&` are tolerated — a too-strict gate + * would let a body like `password=secret&` skip the filter and ship raw. At least one `=` is + * required so prose is never rewritten as a pseudo-form. + */ +function isFormBody(body: string): boolean { + return body.includes('=') && body.split('&').every(segment => FORM_SEGMENT_RE.test(segment)); +} + +/** + * Scrubs the values of known-sensitive keys in an HTTP body the SDK collected itself, before it + * becomes `request.data` or `http.request.body.data`. + * + * Only values the SDK can attribute to a sensitive key are replaced. Everything else passes + * through unchanged: Relay scrubs server-side anyway and cannot tell an SDK-filtered value from a + * literal one, so client-side filtering beyond known-sensitive keys only destroys data. + */ +export function filterCollectedHttpBody(body: unknown): unknown { + if (typeof body === 'string') { + return filterCollectedHttpBodyString(body); + } + + return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body) : body; +} + +/** + * String-only variant of {@link filterCollectedHttpBody}. Capture sites call this before they + * truncate, because a truncated JSON body no longer parses and would pass through unfiltered. + */ +export function filterCollectedHttpBodyString(body: string): string { + if (!body) { + return body; + } + + try { + const json: unknown = JSON.parse(body); + if (typeof json === 'object' && json !== null) { + return JSON.stringify(filterBodyValue(json)); + } + } catch { + // Not JSON. The form-encoded attempt below runs instead. + } + + if (isFormBody(body)) { + // The query-param filter keeps the body's original encoding byte-for-byte. + return filterQueryParams(body, true) ?? body; + } + + return body; +} + +function filterBodyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(filterBodyValue); + } + + if (!isPlainObject(value)) { + return value; + } + + // `Object.fromEntries` instead of assigning `result[key]`, so user-controlled keys like + // `__proto__` never hit a computed property write (CodeQL js/remote-property-injection). + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested), + ]), + ); +} diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 7c92511185ab..aa0f7e537fff 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -9,6 +9,7 @@ import type { CookiePair } from './cookie'; import { parseCookieHeader } from './cookie'; 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 { shouldFilterDataKey } from './data-collection/filterKeyValueData'; import { safeUnref } from './timer'; import { getUrlQuery } from './url'; @@ -160,22 +161,32 @@ 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); const bodyByteLength = bytes.length; + // Requests without a content-length header bypass the early size check, so the hard cap is + // enforced again after reading — both paths skip oversized bodies alike. + if (bodyByteLength > MAX_BODY_BYTE_LENGTH) { + DEBUG_BUILD && debug.log('Skipping body capture: body too large', bodyByteLength); + return; + } + let truncatedBody: string; if (bodyByteLength > maxBodySize) { const decoder = new TextDecoder(); @@ -229,7 +240,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; diff --git a/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts b/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts new file mode 100644 index 000000000000..5748724e94aa --- /dev/null +++ b/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts @@ -0,0 +1,71 @@ +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 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 { + 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, 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('passes a capped stream through truncated, leaving scrubbing to the server side', () => { + // The second chunk is dropped, so the captured prefix is unparseable and passes through raw. + expectCapturedBody( + capture([`{"note":"${'x'.repeat(1200)}"`, '}']), + expect.stringMatching(/^\{"note":"x{988}\.\.\.$/), + ); + }); + + it('passes through a body without key-value structure', () => { + expectCapturedBody(capture(['plain text body']), 'plain text body'); + }); + + it('attaches nothing for an empty body', () => { + expect(capture([])).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts index ecd0382bbdbe..182d12ad6b65 100644 --- a/packages/core/test/lib/trpc.test.ts +++ b/packages/core/test/lib/trpc.test.ts @@ -126,6 +126,42 @@ describe('trpcMiddleware', () => { }); }); + test('filters sensitive keys in the rpc input', async () => { + const middleware = trpcMiddleware({ attachRpcInput: true }); + const next = vi.fn().mockResolvedValue({ ok: true }); + + await middleware({ + path: 'test.procedure', + type: 'mutation', + next, + rawInput: { colour: 'blue', password: 'hunter2' }, + }); + + expect(mockScope.setContext).toHaveBeenCalledWith('trpc', { + procedure_path: 'test.procedure', + procedure_type: 'mutation', + input: { colour: 'blue', password: '[Filtered]' }, + }); + }); + + test('passes through a scalar rpc input, leaving it to server-side scrubbing', async () => { + const middleware = trpcMiddleware({ attachRpcInput: true }); + const next = vi.fn().mockResolvedValue({ ok: true }); + + await middleware({ + path: 'test.procedure', + type: 'mutation', + next, + getRawInput: async () => 'my-session-token', + }); + + expect(mockScope.setContext).toHaveBeenCalledWith('trpc', { + procedure_path: 'test.procedure', + procedure_type: 'mutation', + input: 'my-session-token', + }); + }); + test('handles thrown errors', async () => { const middleware = trpcMiddleware(); const error = new Error('Test error'); diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts new file mode 100644 index 000000000000..07a2fd1a4144 --- /dev/null +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { filterCollectedHttpBody } from '../../../../src/utils/data-collection/filterHttpBody'; + +describe('filterCollectedHttpBody', () => { + it('passes through nullish and empty bodies untouched', () => { + expect(filterCollectedHttpBody(undefined)).toBeUndefined(); + expect(filterCollectedHttpBody(null)).toBeNull(); + expect(filterCollectedHttpBody('')).toBe(''); + }); + + describe('already parsed bodies', () => { + it('filters values for sensitive keys and keeps the rest', () => { + expect(filterCollectedHttpBody({ email: 'a@b.c', password: 'supersecret123' })).toEqual({ + email: 'a@b.c', + password: '[Filtered]', + }); + }); + + it('filters nested objects and arrays', () => { + expect(filterCollectedHttpBody({ users: [{ name: 'jane', api_key: 'abc' }] })).toEqual({ + users: [{ name: 'jane', api_key: '[Filtered]' }], + }); + }); + }); + + describe('JSON string bodies', () => { + it('filters sensitive keys and keeps the string shape', () => { + expect(filterCollectedHttpBody('{"colour":"blue","token":"abc"}')).toBe('{"colour":"blue","token":"[Filtered]"}'); + }); + + it('filters sensitive keys inside arrays of objects', () => { + expect(filterCollectedHttpBody('[{"colour":"blue","token":"abc"}]')).toBe( + '[{"colour":"blue","token":"[Filtered]"}]', + ); + }); + + it('passes through a bare JSON scalar, which has no keys to scrub by', () => { + expect(filterCollectedHttpBody('"just a string"')).toBe('"just a string"'); + expect(filterCollectedHttpBody('42')).toBe('42'); + }); + }); + + describe('form-encoded string bodies', () => { + it('filters sensitive keys while preserving the original encoding', () => { + expect(filterCollectedHttpBody('colour=blue&user%5Bpassword%5D=supersecret123')).toBe( + 'colour=blue&user%5Bpassword%5D=[Filtered]', + ); + }); + + it('keeps a single-field form', () => { + expect(filterCollectedHttpBody('flag=on')).toBe('flag=on'); + }); + + it('filters forms with a trailing ampersand, valueless keys, or empty segments', () => { + expect(filterCollectedHttpBody('password=secret&')).toBe('password=[Filtered]&'); + expect(filterCollectedHttpBody('token=abc&flag')).toBe('token=[Filtered]&flag'); + expect(filterCollectedHttpBody('colour=blue&&password=x')).toBe('colour=blue&&password=[Filtered]'); + }); + }); + + describe('bodies without key-value structure', () => { + // Server-side scrubbing handles these: the SDK cannot attribute any part of them to a specific, sensitive key + it.each([ + ['value'], + ['plain text body'], + ['query Test { people { name } }'], + ['c2VjcmV0LXRva2VuLTEyMw=='], + // These contain a `=` but are not forms; the filter must not rewrite them as one. + [''], + ['--boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\nhunter2\r\n--boundary--'], + ['https://example.com/callback?code=abc123'], + ['total = 42'], + ])('passes through %s unchanged', body => { + expect(filterCollectedHttpBody(body)).toBe(body); + }); + + it('passes through non-string, non-object bodies unchanged', () => { + const buffer = Buffer.from('raw bytes'); + expect(filterCollectedHttpBody(42)).toBe(42); + expect(filterCollectedHttpBody(buffer)).toBe(buffer); + }); + }); +}); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 3266eab7a1e2..cc69d75c4ee0 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -217,6 +217,17 @@ describe('request utils', () => { }); }); + it('filters sensitive keys in a non-standard body', () => { + const actual = httpRequestToRequestData({ + body: { colour: 'blue', password: 'hunter2' }, + } as any); + + expect(actual).toEqual({ + headers: {}, + data: { colour: 'blue', password: '[Filtered]' }, + }); + }); + describe('x-forwarded headers support', () => { it('should prioritize x-forwarded-proto header over explicit protocol parameter', () => { const actual = httpRequestToRequestData({ @@ -1146,6 +1157,18 @@ describe('request utils', () => { expect(scope.capturedData).toBe(jsonBody); }); + it('filters sensitive keys in a JSON body', async () => { + const request = createMockRequest({ + body: JSON.stringify({ colour: 'blue', api_token: 'abc' }), + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('{"colour":"blue","api_token":"[Filtered]"}'); + }); + it('captures form-urlencoded body', async () => { const request = createMockRequest({ body: 'username=test&password=secret', @@ -1155,10 +1178,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('username=test&password=secret'); + expect(scope.capturedData).toBe('username=test&password=[Filtered]'); }); - it('captures text/plain body', async () => { + it('captures text/plain body, leaving scrubbing to the server side', async () => { const request = createMockRequest({ body: 'Hello, World!', contentType: 'text/plain', @@ -1272,10 +1295,10 @@ describe('request utils', () => { }); it('truncates body when it exceeds small size limit (1000 bytes)', async () => { - const largeBody = 'x'.repeat(2000); + const largeBody = `{"note":"${'x'.repeat(2000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1286,10 +1309,10 @@ describe('request utils', () => { }); it('truncates body when it exceeds medium size limit (10000 bytes)', async () => { - const largeBody = 'x'.repeat(20000); + const largeBody = `{"note":"${'x'.repeat(20000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1300,10 +1323,10 @@ describe('request utils', () => { }); it('does not truncate body within small size limit', async () => { - const smallBody = 'x'.repeat(500); + const smallBody = `{"note":"${'x'.repeat(500)}"}`; const request = createMockRequest({ body: smallBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1325,11 +1348,23 @@ describe('request utils', () => { expect(scope.capturedData).toBeUndefined(); }); + it('skips a body over the 1MB limit that arrives without a content-length header', async () => { + const request = createMockRequest({ + body: 'x'.repeat(1_100_000), + contentType: 'text/plain', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'always'); + + expect(scope.capturedData).toBeUndefined(); + }); + it('captures body with always size limit', async () => { - const largeBody = 'x'.repeat(50000); + const largeBody = `{"note":"${'x'.repeat(50000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope();