Skip to content

Commit 32d8fda

Browse files
authored
fix(fetch): Shallow-clone fetch options to prevent mutation (#18867)
When injecting tracing headers into fetch requests, the SDK was mutating the user's original options object. This caused issues when users reused config objects across requests or when the object was frozen (e.g., from Immer's `produce()`), leading to silent failures. This change shallow clones the options object before modifying it, ensuring the original remains unchanged while still properly injecting the `sentry-trace` and `baggage` headers. Closes #18828
1 parent e50b75c commit 32d8fda

File tree

2 files changed

+96
-2
lines changed

2 files changed

+96
-2
lines changed

packages/core/src/fetch.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ export function instrumentFetchRequest(
111111
if (shouldAttachHeaders(handlerData.fetchData.url)) {
112112
const request: string | Request = handlerData.args[0];
113113

114-
const options: { [key: string]: unknown } = handlerData.args[1] || {};
114+
// Shallow clone the options object to avoid mutating the original user-provided object
115+
// Examples: users re-using same options object for multiple fetch calls, frozen objects
116+
const options: { [key: string]: unknown } = { ...(handlerData.args[1] || {}) };
115117

116118
const headers = _addTracingHeadersToFetchRequest(
117119
request,

packages/core/test/lib/fetch.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from 'vitest';
2-
import { _addTracingHeadersToFetchRequest } from '../../src/fetch';
2+
import { _addTracingHeadersToFetchRequest, instrumentFetchRequest } from '../../src/fetch';
3+
import type { Span } from '../../src/types-hoist/span';
34

45
const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE } = vi.hoisted(() => ({
56
DEFAULT_SENTRY_TRACE: 'defaultTraceId-defaultSpanId-1',
@@ -409,3 +410,94 @@ describe('_addTracingHeadersToFetchRequest', () => {
409410
});
410411
});
411412
});
413+
414+
describe('instrumentFetchRequest', () => {
415+
describe('options object mutation', () => {
416+
it('does not mutate the original options object', () => {
417+
const originalOptions = { method: 'POST', body: JSON.stringify({ data: 'test' }) };
418+
const originalOptionsSnapshot = { ...originalOptions };
419+
420+
const handlerData = {
421+
fetchData: { url: '/api/test', method: 'POST' },
422+
args: ['/api/test', originalOptions] as unknown[],
423+
startTimestamp: Date.now(),
424+
};
425+
426+
const spans: Record<string, Span> = {};
427+
428+
instrumentFetchRequest(
429+
handlerData,
430+
() => true,
431+
() => true,
432+
spans,
433+
{ spanOrigin: 'auto.http.browser' },
434+
);
435+
436+
// original options object was not mutated
437+
expect(originalOptions).toEqual(originalOptionsSnapshot);
438+
expect(originalOptions).not.toHaveProperty('headers');
439+
});
440+
441+
it('does not throw with a frozen options object', () => {
442+
const frozenOptions = Object.freeze({ method: 'POST', body: JSON.stringify({ data: 'test' }) });
443+
444+
const handlerData = {
445+
fetchData: { url: '/api/test', method: 'POST' },
446+
args: ['/api/test', frozenOptions] as unknown[],
447+
startTimestamp: Date.now(),
448+
};
449+
450+
const spans: Record<string, Span> = {};
451+
452+
// This should not throw, even though the original object is frozen
453+
expect(() => {
454+
instrumentFetchRequest(
455+
handlerData,
456+
() => true,
457+
() => true,
458+
spans,
459+
{ spanOrigin: 'auto.http.browser' },
460+
);
461+
}).not.toThrow();
462+
463+
// args[1] is a new object with headers (not the frozen one)
464+
const resultOptions = handlerData.args[1] as { headers?: unknown };
465+
expect(resultOptions).toHaveProperty('headers');
466+
expect(resultOptions).not.toBe(frozenOptions);
467+
});
468+
469+
it('preserves existing properties when cloning options', () => {
470+
const originalOptions = {
471+
method: 'POST',
472+
body: JSON.stringify({ data: 'test' }),
473+
credentials: 'include' as const,
474+
mode: 'cors' as const,
475+
};
476+
477+
const handlerData = {
478+
fetchData: { url: '/api/test', method: 'POST' },
479+
args: ['/api/test', originalOptions] as unknown[],
480+
startTimestamp: Date.now(),
481+
};
482+
483+
const spans: Record<string, Span> = {};
484+
485+
instrumentFetchRequest(
486+
handlerData,
487+
() => true,
488+
() => true,
489+
spans,
490+
{ spanOrigin: 'auto.http.browser' },
491+
);
492+
493+
const resultOptions = handlerData.args[1] as Record<string, unknown>;
494+
495+
// all original properties are preserved in the new object
496+
expect(resultOptions.method).toBe('POST');
497+
expect(resultOptions.body).toBe(originalOptions.body);
498+
expect(resultOptions.credentials).toBe('include');
499+
expect(resultOptions.mode).toBe('cors');
500+
expect(resultOptions).toHaveProperty('headers');
501+
});
502+
});
503+
});

0 commit comments

Comments
 (0)