Skip to content

Commit ddda018

Browse files
authored
fix: Prevent fetch errors loops with invalid fetch implementations (#3318)
1 parent a40a0da commit ddda018

File tree

2 files changed

+90
-16
lines changed

2 files changed

+90
-16
lines changed

packages/browser/src/transports/fetch.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,82 @@
11
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2-
import { Event, Response, SentryRequest, Session } from '@sentry/types';
3-
import { getGlobalObject, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
2+
import { Event, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
3+
import { getGlobalObject, logger, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
44

55
import { BaseTransport } from './base';
66

7-
const global = getGlobalObject<Window>();
7+
type FetchImpl = typeof fetch;
8+
9+
/**
10+
* A special usecase for incorrectly wrapped Fetch APIs in conjunction with ad-blockers.
11+
* Whenever someone wraps the Fetch API and returns the wrong promise chain,
12+
* this chain becomes orphaned and there is no possible way to capture it's rejections
13+
* other than allowing it bubble up to this very handler. eg.
14+
*
15+
* const f = window.fetch;
16+
* window.fetch = function () {
17+
* const p = f.apply(this, arguments);
18+
*
19+
* p.then(function() {
20+
* console.log('hi.');
21+
* });
22+
*
23+
* return p;
24+
* }
25+
*
26+
* `p.then(function () { ... })` is producing a completely separate promise chain,
27+
* however, what's returned is `p` - the result of original `fetch` call.
28+
*
29+
* This mean, that whenever we use the Fetch API to send our own requests, _and_
30+
* some ad-blocker blocks it, this orphaned chain will _always_ reject,
31+
* effectively causing another event to be captured.
32+
* This makes a whole process become an infinite loop, which we need to somehow
33+
* deal with, and break it in one way or another.
34+
*
35+
* To deal with this issue, we are making sure that we _always_ use the real
36+
* browser Fetch API, instead of relying on what `window.fetch` exposes.
37+
* The only downside to this would be missing our own requests as breadcrumbs,
38+
* but because we are already not doing this, it should be just fine.
39+
*
40+
* Possible failed fetch error messages per-browser:
41+
*
42+
* Chrome: Failed to fetch
43+
* Edge: Failed to Fetch
44+
* Firefox: NetworkError when attempting to fetch resource
45+
* Safari: resource blocked by content blocker
46+
*/
47+
function getNativeFetchImplementation(): FetchImpl {
48+
// Make sure that the fetch we use is always the native one.
49+
const global = getGlobalObject<Window>();
50+
const document = global.document;
51+
// eslint-disable-next-line deprecation/deprecation
52+
if (typeof document?.createElement === `function`) {
53+
try {
54+
const sandbox = document.createElement('iframe');
55+
sandbox.hidden = true;
56+
document.head.appendChild(sandbox);
57+
if (sandbox.contentWindow?.fetch) {
58+
return sandbox.contentWindow.fetch.bind(global);
59+
}
60+
document.head.removeChild(sandbox);
61+
} catch (e) {
62+
logger.warn('Could not create sandbox iframe for pure fetch check, bailing to window.fetch: ', e);
63+
}
64+
}
65+
return global.fetch.bind(global);
66+
}
867

968
/** `fetch` based transport */
1069
export class FetchTransport extends BaseTransport {
70+
/**
71+
* Fetch API reference which always points to native browser implementation.
72+
*/
73+
private _fetch: typeof fetch;
74+
75+
constructor(options: TransportOptions, fetchImpl: FetchImpl = getNativeFetchImplementation()) {
76+
super(options);
77+
this._fetch = fetchImpl;
78+
}
79+
1180
/**
1281
* @inheritDoc
1382
*/
@@ -54,8 +123,7 @@ export class FetchTransport extends BaseTransport {
54123

55124
return this._buffer.add(
56125
new SyncPromise<Response>((resolve, reject) => {
57-
global
58-
.fetch(sentryRequest.url, options)
126+
this._fetch(sentryRequest.url, options)
59127
.then(response => {
60128
const headers = {
61129
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),

packages/browser/test/unit/transports/fetch.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let transport: Transports.BaseTransport;
1919
describe('FetchTransport', () => {
2020
beforeEach(() => {
2121
fetch = (stub(window, 'fetch') as unknown) as SinonStub;
22-
transport = new Transports.FetchTransport({ dsn: testDsn });
22+
transport = new Transports.FetchTransport({ dsn: testDsn }, window.fetch);
2323
});
2424

2525
afterEach(() => {
@@ -83,12 +83,15 @@ describe('FetchTransport', () => {
8383
});
8484

8585
it('passes in headers', async () => {
86-
transport = new Transports.FetchTransport({
87-
dsn: testDsn,
88-
headers: {
89-
Authorization: 'Basic GVzdDp0ZXN0Cg==',
86+
transport = new Transports.FetchTransport(
87+
{
88+
dsn: testDsn,
89+
headers: {
90+
Authorization: 'Basic GVzdDp0ZXN0Cg==',
91+
},
9092
},
91-
});
93+
window.fetch,
94+
);
9295
const response = { status: 200, headers: new Headers() };
9396

9497
fetch.returns(Promise.resolve(response));
@@ -109,12 +112,15 @@ describe('FetchTransport', () => {
109112
});
110113

111114
it('passes in fetch parameters', async () => {
112-
transport = new Transports.FetchTransport({
113-
dsn: testDsn,
114-
fetchParameters: {
115-
credentials: 'include',
115+
transport = new Transports.FetchTransport(
116+
{
117+
dsn: testDsn,
118+
fetchParameters: {
119+
credentials: 'include',
120+
},
116121
},
117-
});
122+
window.fetch,
123+
);
118124
const response = { status: 200, headers: new Headers() };
119125

120126
fetch.returns(Promise.resolve(response));

0 commit comments

Comments
 (0)