diff --git a/dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs rename to dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs rename to dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/express/with-http/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts similarity index 97% rename from dev-packages/node-integration-tests/suites/express/with-http/test.ts rename to dev-packages/node-integration-tests/suites/express/with-http/base/test.ts index 10dbefa74a9a..40c74a3d8888 100644 --- a/dev-packages/node-integration-tests/suites/express/with-http/test.ts +++ b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; describe('express with http import', () => { afterAll(() => { diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts new file mode 100644 index 000000000000..7b85c82f9ab9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts @@ -0,0 +1,35 @@ +// Payload for requests +export function generatePayload(sizeInBytes: number): { data: string } { + const baseSize = JSON.stringify({ data: '' }).length; + const contentLength = sizeInBytes - baseSize; + + return { data: 'x'.repeat(contentLength) }; +} + +// Generate the "expected" body string +export function generatePayloadString(dataLength: number, truncate?: boolean): string { + const prefix = '{"data":"'; + const suffix = truncate ? '...' : '"}'; + + const baseStructuralLength = prefix.length + suffix.length; + const dataContent = 'x'.repeat(dataLength - baseStructuralLength); + + return `${prefix}${dataContent}${suffix}`; +} + +// Functions for non-ASCII payloads (e.g. emojis) +export function generateEmojiPayload(sizeInBytes: number): { data: string } { + const baseSize = JSON.stringify({ data: '' }).length; + const contentLength = sizeInBytes - baseSize; + + return { data: '👍'.repeat(contentLength) }; +} +export function generateEmojiPayloadString(dataLength: number, truncate?: boolean): string { + const prefix = '{"data":"'; + const suffix = truncate ? '...' : '"}'; + + const baseStructuralLength = suffix.length; + const dataContent = '👍'.repeat(dataLength - baseStructuralLength); + + return `${prefix}${dataContent}${suffix}`; +} diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs new file mode 100644 index 000000000000..9f26662334fb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'always' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs new file mode 100644 index 000000000000..92ed3d0d5d35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'medium' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs new file mode 100644 index 000000000000..609863666ee4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + maxIncomingRequestBodySize: 'none', + ignoreIncomingRequestBody: url => url.includes('/ignore-request-body'), + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs new file mode 100644 index 000000000000..fc13fbe20d31 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'small' })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs new file mode 100644 index 000000000000..c198c8056fea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import bodyParser from 'body-parser'; +import express from 'express'; + +const app = express(); + +// Increase limit for JSON parsing +app.use(bodyParser.json({ limit: '3mb' })); +app.use(express.json({ limit: '3mb' })); + +app.post('/test-body-size', (req, res) => { + const receivedSize = JSON.stringify(req.body).length; + res.json({ + success: true, + receivedSize, + message: 'Payload processed successfully', + }); +}); + +app.post('/ignore-request-body', (req, res) => { + const receivedSize = JSON.stringify(req.body).length; + res.json({ + success: true, + receivedSize, + message: 'Payload processed successfully', + }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts new file mode 100644 index 000000000000..5ae6b4e2bacc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts @@ -0,0 +1,311 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { + generateEmojiPayload, + generateEmojiPayloadString, + generatePayload, + generatePayloadString, +} from './generatePayload'; + +// Value of MAX_BODY_BYTE_LENGTH in SentryHttpIntegration +const MAX_GENERAL = 1024 * 1024; // 1MB +const MAX_MEDIUM = 10_000; +const MAX_SMALL = 1000; + +describe('express with httpIntegration and not defined maxIncomingRequestBodySize', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-default.mjs', (createRunner, test) => { + test('captures medium request bodies with default setting (medium)', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }); + + await runner.completed(); + }); + + test('truncates large request bodies with default setting (medium)', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_MEDIUM, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)), + }); + + await runner.completed(); + }); + }); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "none"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-none.mjs', + (createRunner, test) => { + test('does not capture any request bodies with "none" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.completed(); + }); + + test('does not capture any request bodies with "none" setting and "ignoreIncomingRequestBody"', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .expect({ + transaction: { + transaction: 'POST /ignore-request-body', + request: expect.not.objectContaining({ + data: expect.any(String), + }), + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.makeRequest('post', '/ignore-request-body', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(500)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "always"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-always.mjs', + (createRunner, test) => { + test('captures maximum allowed request body length with "always" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_GENERAL)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_GENERAL)), + }); + + await runner.completed(); + }); + + test('captures large request bodies with "always" setting but respects maximum size limit', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_GENERAL, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_GENERAL + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "small"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-small.mjs', + (createRunner, test) => { + test('keeps small request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_SMALL)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_SMALL)), + }); + + await runner.completed(); + }); + + test('truncates too large request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_SMALL, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_SMALL + 1)), + }); + + await runner.completed(); + }); + + test('truncates too large non-ASCII request bodies with "small" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + // 250 emojis, each 4 bytes in UTF-8 (resulting in 1000 bytes --> MAX_SMALL) + data: generateEmojiPayloadString(250, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generateEmojiPayload(MAX_SMALL + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); + +describe('express with httpIntegration and maxIncomingRequestBodySize: "medium"', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-medium.mjs', + (createRunner, test) => { + test('keeps medium request bodies with "medium" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM)), + }); + + await runner.completed(); + }); + + test('truncates large request bodies with "medium" setting', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: generatePayloadString(MAX_MEDIUM, true), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)), + }); + + await runner.completed(); + }); + }, + { failsOnEsm: false }, + ); +}); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 4e044879d2aa..40d0d59e8a1d 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -69,6 +69,22 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + /** * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. * Read more about Release Health: https://docs.sentry.io/product/releases/health/ @@ -226,7 +242,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase) => { try { const chunk = args[0] as Buffer | string; const bufferifiedChunk = Buffer.from(chunk); - if (bodyByteLength < MAX_BODY_BYTE_LENGTH) { + if (bodyByteLength < maxBodySize) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; } else if (DEBUG_BUILD) { logger.log( INSTRUMENTATION_NAME, - `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, ); } } catch (err) { @@ -429,7 +458,16 @@ function patchRequestToCaptureBody(req: http.IncomingMessage, isolationScope: Sc try { const body = Buffer.concat(chunks).toString('utf-8'); if (body) { - isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } }); + // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long + const bodyByteLength = Buffer.byteLength(body, 'utf-8'); + const truncatedBody = + bodyByteLength > maxBodySize + ? `${Buffer.from(body) + .subarray(0, maxBodySize - 3) + .toString('utf-8')}...` + : body; + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); } } catch (error) { if (DEBUG_BUILD) { diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 9d1113702410..b9ce9505ad76 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -90,6 +90,22 @@ interface HttpOptions { */ ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean; + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + /** * If true, do not generate spans for incoming requests at all. * This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans.