From c943d351c69c231ad2661df3870fc22b33381fe9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:09:53 +0200 Subject: [PATCH 1/8] fix(core): Filter collected HTTP bodies and redact browser GraphQL document literals --- .../suites/express/tracing/test.ts | 8 +-- .../suites/express/without-tracing/test.ts | 8 +-- .../browser/src/integrations/graphqlClient.ts | 13 +++- .../test/integrations/graphqlClient.test.ts | 21 ++++++ packages/cloudflare/test/request.test.ts | 4 +- .../http/patch-request-to-capture-body.ts | 20 ++++-- .../utils/data-collection/filterHttpBody.ts | 64 ++++++++++++++++++ packages/core/src/utils/request.ts | 12 ++-- .../patch-request-to-capture-body.test.ts | 67 +++++++++++++++++++ .../data-collection/filterHttpBody.test.ts | 58 ++++++++++++++++ packages/core/test/lib/utils/request.test.ts | 46 ++++++++----- 11 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/utils/data-collection/filterHttpBody.ts create mode 100644 packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts create mode 100644 packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 8f86f80bbd04..85b72d6b100f 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -305,7 +305,8 @@ describe('express 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]', }, }, }) @@ -330,7 +331,7 @@ describe('express tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - data: 'some plain text in buffer', + data: '[Filtered]', }, }, }) @@ -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]', }, }, }) diff --git a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts index ea07c84226e3..d183c6892848 100644 --- a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts @@ -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]', }, }, }) @@ -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]', }, }, }) @@ -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]', }, }, }) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 643ced098f0f..190b66a48792 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -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, @@ -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 @@ -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)) { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 92dabd9adf53..24095d8478d5 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -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 { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index adfd8c5f848b..a7af9300e864 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..17056c3c50af 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,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'; /** @@ -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'); @@ -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.'); @@ -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'); 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..af98358e2849 --- /dev/null +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -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; +} + +function filterBodyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(filterBodyValue); + } + + if (!isPlainObject(value)) { + return value; + } + + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); + } + return result; +} diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index b013f09e8ce6..3abd130dd13b 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -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'; @@ -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); @@ -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; 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..219aabbaff2f --- /dev/null +++ b/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts @@ -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 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('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(); + }); +}); 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..27df04cb6daf --- /dev/null +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -0,0 +1,58 @@ +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 a bare JSON scalar, which has no keys to match against', () => { + expect(filterCollectedHttpBody('"just a string"')).toBe('[Filtered]'); + expect(filterCollectedHttpBody('42')).toBe('[Filtered]'); + }); + }); + + 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]', + ); + }); + }); + + describe('unparseable bodies', () => { + it.each([['value'], ['plain text body'], ['query Test { people { name } }']])( + 'replaces %s with the filtered value', + body => { + expect(filterCollectedHttpBody(body)).toBe('[Filtered]'); + }, + ); + + it('replaces bodies that are not a key-value structure', () => { + expect(filterCollectedHttpBody(42)).toBe('[Filtered]'); + expect(filterCollectedHttpBody(Buffer.from('raw bytes'))).toBe('[Filtered]'); + }); + }); +}); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 4db75d5a96ff..eb2f4e9745c6 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1095,6 +1095,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', @@ -1104,10 +1116,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('filters a text/plain body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'Hello, World!', contentType: 'text/plain', @@ -1116,10 +1128,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('Hello, World!'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures text/html body', async () => { + it('filters a text/html body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'Test', contentType: 'text/html', @@ -1128,10 +1140,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('Test'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures application/xml body', async () => { + it('filters an application/xml body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'value', contentType: 'application/xml', @@ -1140,10 +1152,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('value'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures application/graphql body', async () => { + it('filters an application/graphql body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'query { user { name } }', contentType: 'application/graphql', @@ -1152,7 +1164,7 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('query { user { name } }'); + expect(scope.capturedData).toBe('[Filtered]'); }); it('skips non-textual content types', async () => { @@ -1221,10 +1233,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(); @@ -1235,10 +1247,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(); @@ -1249,10 +1261,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(); @@ -1275,10 +1287,10 @@ describe('request utils', () => { }); 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(); From d553f794c2598e85f25cce31dcdaa55e84319c10 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:10:40 +0200 Subject: [PATCH 2/8] additional fixes and tests --- .../test/integrations/graphqlClient.test.ts | 78 ++++++++++++++++++- .../utils/data-collection/filterHttpBody.ts | 39 +++++++--- .../data-collection/filterHttpBody.test.ts | 30 +++++-- packages/core/test/lib/utils/request.test.ts | 11 +++ 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 24095d8478d5..513eceedc321 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'; @@ -397,6 +397,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({ @@ -533,4 +560,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/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts index af98358e2849..4f22f7b17bf9 100644 --- a/packages/core/src/utils/data-collection/filterHttpBody.ts +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -3,8 +3,18 @@ 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 = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/; +/** + * Matches `key=value&key2=value2` bodies, the only non-JSON shape whose keys the denylist can check. + * 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 prose, URLs, XML, and + * base64 blobs never pass as a pseudo-form whose "keys" would then ship unfiltered. + */ +const FORM_BODY_RE = /^[\w%.*+-]+=[^&\s]*(?:&[\w%.*+-]+=[^&\s]*)*$/; + +function looksLikeFormBody(body: string): boolean { + // A lone `key=` token is more likely base64 padding than a one-field form, so it does not count. + return FORM_BODY_RE.test(body) && (body.includes('&') || !body.endsWith('=')); +} /** * Scrubs an HTTP body the SDK collected itself, before it becomes `request.data` or @@ -21,7 +31,7 @@ export function filterCollectedHttpBody(body: unknown): unknown { } // 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; + return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body, false) : FILTERED_VALUE; } /** @@ -37,28 +47,37 @@ export function filterCollectedHttpBodyString(body: string): string { 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)); + return JSON.stringify(filterBodyValue(json, false)); } } 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; + if (looksLikeFormBody(body)) { + // The query-param filter keeps the body's original encoding byte-for-byte. + return filterQueryParams(body, true) ?? FILTERED_VALUE; + } + + return FILTERED_VALUE; } -function filterBodyValue(value: unknown): unknown { +/** + * `keyVouched` tracks whether a non-sensitive key sits above this value. A scalar without such a + * key (a top-level array element like `["my-secret-token"]`) has nothing the denylist can clear it + * by, so it is filtered — same reasoning as a bare scalar body. + */ +function filterBodyValue(value: unknown, keyVouched: boolean): unknown { if (Array.isArray(value)) { - return value.map(filterBodyValue); + return value.map(entry => filterBodyValue(entry, keyVouched)); } if (!isPlainObject(value)) { - return value; + return keyVouched ? value : FILTERED_VALUE; } const result: Record = {}; for (const [key, nested] of Object.entries(value)) { - result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); + result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested, true); } return result; } diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts index 27df04cb6daf..35f676954a2c 100644 --- a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -32,6 +32,14 @@ describe('filterCollectedHttpBody', () => { expect(filterCollectedHttpBody('"just a string"')).toBe('[Filtered]'); expect(filterCollectedHttpBody('42')).toBe('[Filtered]'); }); + + it('filters scalar array elements with no key vouching for them', () => { + expect(filterCollectedHttpBody('["my-secret-token"]')).toBe('["[Filtered]"]'); + expect(filterCollectedHttpBody(['my-secret-token'])).toEqual(['[Filtered]']); + expect(filterCollectedHttpBody('[{"colour":"blue","token":"abc"},"stray"]')).toBe( + '[{"colour":"blue","token":"[Filtered]"},"[Filtered]"]', + ); + }); }); describe('form-encoded string bodies', () => { @@ -40,15 +48,25 @@ describe('filterCollectedHttpBody', () => { 'colour=blue&user%5Bpassword%5D=[Filtered]', ); }); + + it('keeps a single-field form', () => { + expect(filterCollectedHttpBody('flag=on')).toBe('flag=on'); + }); }); describe('unparseable bodies', () => { - it.each([['value'], ['plain text body'], ['query Test { people { name } }']])( - 'replaces %s with the filtered value', - body => { - expect(filterCollectedHttpBody(body)).toBe('[Filtered]'); - }, - ); + it.each([ + ['value'], + ['plain text body'], + ['query Test { people { name } }'], + // These contain a `=` but are not forms; their pseudo-keys must not ship unfiltered. + ['c2VjcmV0='], + ['dG9rZW4uMg=='], + ['total = 42'], + ['https://example.com/callback?code=abc123'], + ])('replaces %s with the filtered value', body => { + expect(filterCollectedHttpBody(body)).toBe('[Filtered]'); + }); it('replaces bodies that are not a key-value structure', () => { expect(filterCollectedHttpBody(42)).toBe('[Filtered]'); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 4bda7a967d40..0154e468e14b 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({ From 2216e265f1135f4351bf659614a21607ed3184ee Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:26:25 +0200 Subject: [PATCH 3/8] add trcp and trimming --- packages/core/src/trpc.ts | 7 ++-- packages/core/src/utils/request.ts | 4 ++- packages/core/test/lib/trpc.test.ts | 36 ++++++++++++++++++++ packages/core/test/lib/utils/request.test.ts | 4 +-- 4 files changed, 46 insertions(+), 5 deletions(-) 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/request.ts b/packages/core/src/utils/request.ts index 827d2554c627..2dc90b285e0a 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -150,7 +150,9 @@ export async function captureBodyFromWinterCGRequest( if (contentLength) { const length = parseInt(contentLength, 10); if (!isNaN(length) && length > MAX_BODY_BYTE_LENGTH) { - DEBUG_BUILD && debug.log('Skipping body capture: body too large', length); + // Too large to read and scrub, but the marker still records that a body existed (matching Node capped-stream behavior) + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: FILTERED_VALUE } }); + DEBUG_BUILD && debug.log('Body exceeds size cap, attaching filtered marker', length); return; } } diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts index ecd0382bbdbe..df173c4e2c6b 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('filters a scalar rpc input, which has no keys to scrub by', 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: '[Filtered]', + }); + }); + test('handles thrown errors', async () => { const middleware = trpcMiddleware(); const error = new Error('Test error'); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 0154e468e14b..33958cbb1be9 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1335,7 +1335,7 @@ describe('request utils', () => { expect(scope.capturedData).toBe(smallBody); }); - it('skips when content-length exceeds 1MB limit', async () => { + it('attaches the filtered marker when content-length exceeds the 1MB limit', async () => { const request = createMockRequest({ body: 'small body', contentType: 'application/json', @@ -1345,7 +1345,7 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'always'); - expect(scope.capturedData).toBeUndefined(); + expect(scope.capturedData).toBe('[Filtered]'); }); it('captures body with always size limit', async () => { From 4911a6c604985b062aa69bceb4f6d02bbe2fb6d0 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:49:20 +0200 Subject: [PATCH 4/8] simplify scrubbing --- .size-limit.js | 2 +- .../suites/express/tracing/test.ts | 8 +-- .../suites/express/without-tracing/test.ts | 8 +-- .../http/patch-request-to-capture-body.ts | 18 ++---- .../utils/data-collection/filterHttpBody.ts | 57 +++++++------------ packages/core/src/utils/request.ts | 4 +- .../patch-request-to-capture-body.test.ts | 12 ++-- packages/core/test/lib/trpc.test.ts | 4 +- .../data-collection/filterHttpBody.test.ts | 36 ++++++------ packages/core/test/lib/utils/request.test.ts | 20 +++---- 10 files changed, 72 insertions(+), 97 deletions(-) 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/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 0da4b69d1511..b5637d14ab1c 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -329,8 +329,7 @@ describe('express tracing', () => { 'http.request.method': { type: 'string', value: 'POST' }, 'http.request.header.user-agent': { type: 'array', value: [expect.stringContaining('')] }, 'http.request.header.content-type': { type: 'array', value: ['text/plain'] }, - // A plain-text body has no keys the denylist can match, so it is filtered completely. - 'http.request.body.data': { type: 'string', value: '[Filtered]' }, + 'http.request.body.data': { type: 'string', value: 'some plain text' }, }), }); }, @@ -355,7 +354,7 @@ describe('express tracing', () => { 'http.request.method': { type: 'string', value: 'POST' }, 'http.request.header.user-agent': { type: 'array', value: [expect.stringContaining('')] }, 'http.request.header.content-type': { type: 'array', value: ['application/octet-stream'] }, - 'http.request.body.data': { type: 'string', value: '[Filtered]' }, + 'http.request.body.data': { type: 'string', value: 'some plain text in buffer' }, }), }); }, @@ -380,7 +379,8 @@ describe('express tracing', () => { 'http.request.method': { type: 'string', value: 'POST' }, 'http.request.header.user-agent': { type: 'array', value: [expect.stringContaining('')] }, 'http.request.header.content-type': { type: 'array', value: ['application/octet-stream'] }, - 'http.request.body.data': { type: 'string', value: '[Filtered]' }, + // This is some non-ascii string representation + 'http.request.body.data': { type: 'string', value: expect.any(String) }, }), }); }, diff --git a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts index d183c6892848..ea07c84226e3 100644 --- a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts @@ -76,8 +76,7 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'text/plain', }, - // A plain-text body has no keys the denylist can match, so it is filtered completely. - data: '[Filtered]', + data: 'some plain text', }, }, }) @@ -104,7 +103,7 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - data: '[Filtered]', + data: 'some plain text in buffer', }, }, }) @@ -129,7 +128,8 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - data: '[Filtered]', + // This is some non-ascii string representation + data: expect.any(String), }, }, }) 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 17056c3c50af..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,7 +2,6 @@ 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'; @@ -21,7 +20,6 @@ export function patchRequestToCaptureBody( ): void { let bodyByteLength = 0; const chunks: Buffer[] = []; - let chunksDropped = false; DEBUG_BUILD && debug.log(integrationName, 'Patching request.on'); @@ -51,13 +49,11 @@ export function patchRequestToCaptureBody( if (bodyByteLength < maxBodySize) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; - } else { - chunksDropped = true; - DEBUG_BUILD && - debug.log( - integrationName, - `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, - ); + } else if (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.'); @@ -97,10 +93,8 @@ export function patchRequestToCaptureBody( req.on('end', () => { try { - 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)); + 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/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts index 4f22f7b17bf9..b5e3363412f9 100644 --- a/packages/core/src/utils/data-collection/filterHttpBody.ts +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -3,40 +3,28 @@ 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. - * 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 prose, URLs, XML, and - * base64 blobs never pass as a pseudo-form whose "keys" would then ship unfiltered. - */ -const FORM_BODY_RE = /^[\w%.*+-]+=[^&\s]*(?:&[\w%.*+-]+=[^&\s]*)*$/; - -function looksLikeFormBody(body: string): boolean { - // A lone `key=` token is more likely base64 padding than a one-field form, so it does not count. - return FORM_BODY_RE.test(body) && (body.includes('&') || !body.endsWith('=')); -} +/** 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]`. + * 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 (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, false) : FILTERED_VALUE; + 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 be dropped wholesale. + * truncate, because a truncated JSON body no longer parses and would pass through unfiltered. */ export function filterCollectedHttpBodyString(body: string): string { if (!body) { @@ -45,39 +33,34 @@ export function filterCollectedHttpBodyString(body: string): string { 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, false)); + return JSON.stringify(filterBodyValue(json)); } } catch { // Not JSON. The form-encoded attempt below runs instead. } - if (looksLikeFormBody(body)) { + if (FORM_BODY_RE.test(body)) { // The query-param filter keeps the body's original encoding byte-for-byte. - return filterQueryParams(body, true) ?? FILTERED_VALUE; + return filterQueryParams(body, true) ?? body; } - return FILTERED_VALUE; + return body; } -/** - * `keyVouched` tracks whether a non-sensitive key sits above this value. A scalar without such a - * key (a top-level array element like `["my-secret-token"]`) has nothing the denylist can clear it - * by, so it is filtered — same reasoning as a bare scalar body. - */ -function filterBodyValue(value: unknown, keyVouched: boolean): unknown { +function filterBodyValue(value: unknown): unknown { if (Array.isArray(value)) { - return value.map(entry => filterBodyValue(entry, keyVouched)); + return value.map(filterBodyValue); } if (!isPlainObject(value)) { - return keyVouched ? value : FILTERED_VALUE; + return value; } - const result: Record = {}; + // Null prototype so user-controlled keys like `__proto__` cannot pollute (CodeQL js/remote-property-injection). + const result: Record = Object.create(null); for (const [key, nested] of Object.entries(value)) { - result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested, true); + result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); } return result; } diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 2dc90b285e0a..827d2554c627 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -150,9 +150,7 @@ export async function captureBodyFromWinterCGRequest( if (contentLength) { const length = parseInt(contentLength, 10); if (!isNaN(length) && length > MAX_BODY_BYTE_LENGTH) { - // Too large to read and scrub, but the marker still records that a body existed (matching Node capped-stream behavior) - isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: FILTERED_VALUE } }); - DEBUG_BUILD && debug.log('Body exceeds size cap, attaching filtered marker', length); + DEBUG_BUILD && debug.log('Skipping body capture: body too large', length); return; } } 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 index 219aabbaff2f..5748724e94aa 100644 --- 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 @@ -53,12 +53,16 @@ describe('patchRequestToCaptureBody', () => { 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('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('filters a body that cannot be parsed into key-value pairs', () => { - expectCapturedBody(capture(['plain text body']), '[Filtered]'); + it('passes through a body without key-value structure', () => { + expectCapturedBody(capture(['plain text body']), 'plain text body'); }); it('attaches nothing for an empty body', () => { diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts index df173c4e2c6b..182d12ad6b65 100644 --- a/packages/core/test/lib/trpc.test.ts +++ b/packages/core/test/lib/trpc.test.ts @@ -144,7 +144,7 @@ describe('trpcMiddleware', () => { }); }); - test('filters a scalar rpc input, which has no keys to scrub by', async () => { + 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 }); @@ -158,7 +158,7 @@ describe('trpcMiddleware', () => { expect(mockScope.setContext).toHaveBeenCalledWith('trpc', { procedure_path: 'test.procedure', procedure_type: 'mutation', - input: '[Filtered]', + input: 'my-session-token', }); }); diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts index 35f676954a2c..de680584cb35 100644 --- a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -28,17 +28,15 @@ describe('filterCollectedHttpBody', () => { expect(filterCollectedHttpBody('{"colour":"blue","token":"abc"}')).toBe('{"colour":"blue","token":"[Filtered]"}'); }); - it('filters a bare JSON scalar, which has no keys to match against', () => { - expect(filterCollectedHttpBody('"just a string"')).toBe('[Filtered]'); - expect(filterCollectedHttpBody('42')).toBe('[Filtered]'); + it('filters sensitive keys inside arrays of objects', () => { + expect(filterCollectedHttpBody('[{"colour":"blue","token":"abc"}]')).toBe( + '[{"colour":"blue","token":"[Filtered]"}]', + ); }); - it('filters scalar array elements with no key vouching for them', () => { - expect(filterCollectedHttpBody('["my-secret-token"]')).toBe('["[Filtered]"]'); - expect(filterCollectedHttpBody(['my-secret-token'])).toEqual(['[Filtered]']); - expect(filterCollectedHttpBody('[{"colour":"blue","token":"abc"},"stray"]')).toBe( - '[{"colour":"blue","token":"[Filtered]"},"[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'); }); }); @@ -54,23 +52,21 @@ describe('filterCollectedHttpBody', () => { }); }); - describe('unparseable bodies', () => { + 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 } }'], - // These contain a `=` but are not forms; their pseudo-keys must not ship unfiltered. - ['c2VjcmV0='], - ['dG9rZW4uMg=='], - ['total = 42'], - ['https://example.com/callback?code=abc123'], - ])('replaces %s with the filtered value', body => { - expect(filterCollectedHttpBody(body)).toBe('[Filtered]'); + ['c2VjcmV0LXRva2VuLTEyMw=='], + ])('passes through %s unchanged', body => { + expect(filterCollectedHttpBody(body)).toBe(body); }); - it('replaces bodies that are not a key-value structure', () => { - expect(filterCollectedHttpBody(42)).toBe('[Filtered]'); - expect(filterCollectedHttpBody(Buffer.from('raw bytes'))).toBe('[Filtered]'); + 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 33958cbb1be9..7014f969ac4a 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1181,7 +1181,7 @@ describe('request utils', () => { expect(scope.capturedData).toBe('username=test&password=[Filtered]'); }); - it('filters a text/plain body, which has no keys to scrub by', async () => { + it('captures text/plain body, leaving scrubbing to the server side', async () => { const request = createMockRequest({ body: 'Hello, World!', contentType: 'text/plain', @@ -1190,10 +1190,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('[Filtered]'); + expect(scope.capturedData).toBe('Hello, World!'); }); - it('filters a text/html body, which has no keys to scrub by', async () => { + it('captures text/html body', async () => { const request = createMockRequest({ body: 'Test', contentType: 'text/html', @@ -1202,10 +1202,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('[Filtered]'); + expect(scope.capturedData).toBe('Test'); }); - it('filters an application/xml body, which has no keys to scrub by', async () => { + it('captures application/xml body', async () => { const request = createMockRequest({ body: 'value', contentType: 'application/xml', @@ -1214,10 +1214,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('[Filtered]'); + expect(scope.capturedData).toBe('value'); }); - it('filters an application/graphql body, which has no keys to scrub by', async () => { + it('captures application/graphql body', async () => { const request = createMockRequest({ body: 'query { user { name } }', contentType: 'application/graphql', @@ -1226,7 +1226,7 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('[Filtered]'); + expect(scope.capturedData).toBe('query { user { name } }'); }); it('skips non-textual content types', async () => { @@ -1335,7 +1335,7 @@ describe('request utils', () => { expect(scope.capturedData).toBe(smallBody); }); - it('attaches the filtered marker when content-length exceeds the 1MB limit', async () => { + it('skips when content-length exceeds 1MB limit', async () => { const request = createMockRequest({ body: 'small body', contentType: 'application/json', @@ -1345,7 +1345,7 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'always'); - expect(scope.capturedData).toBe('[Filtered]'); + expect(scope.capturedData).toBeUndefined(); }); it('captures body with always size limit', async () => { From 7eca651271666eb42ffbc1b227bc75562f49fcdd Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:03 +0200 Subject: [PATCH 5/8] fix --- .../src/utils/data-collection/filterHttpBody.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts index b5e3363412f9..7068a978ce8e 100644 --- a/packages/core/src/utils/data-collection/filterHttpBody.ts +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -57,10 +57,12 @@ function filterBodyValue(value: unknown): unknown { return value; } - // Null prototype so user-controlled keys like `__proto__` cannot pollute (CodeQL js/remote-property-injection). - const result: Record = Object.create(null); - for (const [key, nested] of Object.entries(value)) { - result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); - } - return result; + // `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), + ]), + ); } From e93cb39ff5e9f9208a4d9884d4299c0cf2c4b5e2 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:15:52 +0200 Subject: [PATCH 6/8] fix test --- .../suites/integrations/http-server/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 4190fe437a92a471c92433db7713a3cbb1ada13f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:51:02 +0200 Subject: [PATCH 7/8] form body additions --- .../src/utils/data-collection/filterHttpBody.ts | 16 +++++++++++++--- .../utils/data-collection/filterHttpBody.test.ts | 6 ++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts index 7068a978ce8e..fa53ae24b6bd 100644 --- a/packages/core/src/utils/data-collection/filterHttpBody.ts +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -3,8 +3,18 @@ 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 = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/; +/** One `&`-separated form segment: empty, a bare key, or `key=value`. */ +const FORM_SEGMENT_RE = /^(?:[^=]+(?:=.*)?)?$/; + +/** + * 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 @@ -40,7 +50,7 @@ export function filterCollectedHttpBodyString(body: string): string { // Not JSON. The form-encoded attempt below runs instead. } - if (FORM_BODY_RE.test(body)) { + if (isFormBody(body)) { // The query-param filter keeps the body's original encoding byte-for-byte. return filterQueryParams(body, true) ?? body; } diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts index de680584cb35..8dc16fb09267 100644 --- a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -50,6 +50,12 @@ describe('filterCollectedHttpBody', () => { 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', () => { From ab6c46888cc60bfa3b5d45eae7ee4ab101f121e7 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:04:22 +0200 Subject: [PATCH 8/8] review suggestions --- packages/browser/src/integrations/graphqlClient.ts | 4 +++- .../browser/test/integrations/graphqlClient.test.ts | 3 +++ .../core/src/utils/data-collection/filterHttpBody.ts | 9 +++++++-- packages/core/src/utils/request.ts | 7 +++++++ .../lib/utils/data-collection/filterHttpBody.test.ts | 5 +++++ packages/core/test/lib/utils/request.test.ts | 12 ++++++++++++ 6 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 190b66a48792..f5b649864ae0 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -54,7 +54,9 @@ 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; +// 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 { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 513eceedc321..6fee9db6beb6 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -25,6 +25,9 @@ describe('_redactGraphqlDocument', () => { }); 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 } }', ); diff --git a/packages/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts index fa53ae24b6bd..9da018a53ab6 100644 --- a/packages/core/src/utils/data-collection/filterHttpBody.ts +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -3,8 +3,13 @@ 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`. */ -const FORM_SEGMENT_RE = /^(?:[^=]+(?:=.*)?)?$/; +/** + * 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 diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 827d2554c627..aa0f7e537fff 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -180,6 +180,13 @@ export async function captureBodyFromWinterCGRequest( 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(); diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts index 8dc16fb09267..07a2fd1a4144 100644 --- a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -65,6 +65,11 @@ describe('filterCollectedHttpBody', () => { ['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); }); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 7014f969ac4a..cc69d75c4ee0 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1348,6 +1348,18 @@ 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 = `{"note":"${'x'.repeat(50000)}"}`; const request = createMockRequest({