diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index b16672430a5d..5b1a73d5a192 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -111,7 +111,9 @@ export function instrumentFetchRequest( if (shouldAttachHeaders(handlerData.fetchData.url)) { const request: string | Request = handlerData.args[0]; - const options: { [key: string]: unknown } = handlerData.args[1] || {}; + // Shallow clone the options object to avoid mutating the original user-provided object + // Examples: users re-using same options object for multiple fetch calls, frozen objects + const options: { [key: string]: unknown } = { ...(handlerData.args[1] || {}) }; const headers = _addTracingHeadersToFetchRequest( request, diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index cafc22a562c8..47572ab83159 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { _addTracingHeadersToFetchRequest } from '../../src/fetch'; +import { _addTracingHeadersToFetchRequest, instrumentFetchRequest } from '../../src/fetch'; +import type { Span } from '../../src/types-hoist/span'; const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE } = vi.hoisted(() => ({ DEFAULT_SENTRY_TRACE: 'defaultTraceId-defaultSpanId-1', @@ -409,3 +410,94 @@ describe('_addTracingHeadersToFetchRequest', () => { }); }); }); + +describe('instrumentFetchRequest', () => { + describe('options object mutation', () => { + it('does not mutate the original options object', () => { + const originalOptions = { method: 'POST', body: JSON.stringify({ data: 'test' }) }; + const originalOptionsSnapshot = { ...originalOptions }; + + const handlerData = { + fetchData: { url: '/api/test', method: 'POST' }, + args: ['/api/test', originalOptions] as unknown[], + startTimestamp: Date.now(), + }; + + const spans: Record = {}; + + instrumentFetchRequest( + handlerData, + () => true, + () => true, + spans, + { spanOrigin: 'auto.http.browser' }, + ); + + // original options object was not mutated + expect(originalOptions).toEqual(originalOptionsSnapshot); + expect(originalOptions).not.toHaveProperty('headers'); + }); + + it('does not throw with a frozen options object', () => { + const frozenOptions = Object.freeze({ method: 'POST', body: JSON.stringify({ data: 'test' }) }); + + const handlerData = { + fetchData: { url: '/api/test', method: 'POST' }, + args: ['/api/test', frozenOptions] as unknown[], + startTimestamp: Date.now(), + }; + + const spans: Record = {}; + + // This should not throw, even though the original object is frozen + expect(() => { + instrumentFetchRequest( + handlerData, + () => true, + () => true, + spans, + { spanOrigin: 'auto.http.browser' }, + ); + }).not.toThrow(); + + // args[1] is a new object with headers (not the frozen one) + const resultOptions = handlerData.args[1] as { headers?: unknown }; + expect(resultOptions).toHaveProperty('headers'); + expect(resultOptions).not.toBe(frozenOptions); + }); + + it('preserves existing properties when cloning options', () => { + const originalOptions = { + method: 'POST', + body: JSON.stringify({ data: 'test' }), + credentials: 'include' as const, + mode: 'cors' as const, + }; + + const handlerData = { + fetchData: { url: '/api/test', method: 'POST' }, + args: ['/api/test', originalOptions] as unknown[], + startTimestamp: Date.now(), + }; + + const spans: Record = {}; + + instrumentFetchRequest( + handlerData, + () => true, + () => true, + spans, + { spanOrigin: 'auto.http.browser' }, + ); + + const resultOptions = handlerData.args[1] as Record; + + // all original properties are preserved in the new object + expect(resultOptions.method).toBe('POST'); + expect(resultOptions.body).toBe(originalOptions.body); + expect(resultOptions.credentials).toBe('include'); + expect(resultOptions.mode).toBe('cors'); + expect(resultOptions).toHaveProperty('headers'); + }); + }); +});