Skip to content

feat(browser): Disable client when browser extension is detected in init() #16354

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 26, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -18,7 +18,12 @@ sentryTest(
return !!(window as any).Sentry.isInitialized();
});

expect(isInitialized).toEqual(false);
const isEnabled = await page.evaluate(() => {
return !!(window as any).Sentry.getClient()?.getOptions().enabled;
});

expect(isInitialized).toEqual(true);
expect(isEnabled).toEqual(false);

if (hasDebugLogs()) {
expect(errorLogs.length).toEqual(1);
Original file line number Diff line number Diff line change
@@ -16,7 +16,12 @@ sentryTest('should not initialize when inside a Chrome browser extension', async
return !!(window as any).Sentry.isInitialized();
});

expect(isInitialized).toEqual(false);
const isEnabled = await page.evaluate(() => {
return !!(window as any).Sentry.getClient()?.getOptions().enabled;
});

expect(isInitialized).toEqual(true);
expect(isEnabled).toEqual(false);

if (hasDebugLogs()) {
expect(errorLogs.length).toEqual(1);
40 changes: 27 additions & 13 deletions packages/browser/src/client.ts
Original file line number Diff line number Diff line change
@@ -21,15 +21,24 @@ import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
import type { BrowserTransportOptions } from './transports/types';

/**
* A magic string that build tooling can leverage in order to inject a release value into the SDK.
*/
declare const __SENTRY_RELEASE__: string | undefined;

const DEFAULT_FLUSH_INTERVAL = 5000;

type BrowserSpecificOptions = BrowserClientReplayOptions &
BrowserClientProfilingOptions & {
/** If configured, this URL will be used as base URL for lazy loading integration. */
cdnBaseUrl?: string;
};
/**
* Configuration options for the Sentry Browser SDK.
* @see @sentry/core Options for more information.
*/
export type BrowserOptions = Options<BrowserTransportOptions> &
BrowserClientReplayOptions &
BrowserClientProfilingOptions & {
BrowserSpecificOptions & {
/**
* Important: Only set this option if you know what you are doing!
*
@@ -54,12 +63,7 @@ export type BrowserOptions = Options<BrowserTransportOptions> &
* Configuration options for the Sentry Browser SDK Client class
* @see BrowserClient for more information.
*/
export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> &
BrowserClientReplayOptions &
BrowserClientProfilingOptions & {
/** If configured, this URL will be used as base URL for lazy loading integration. */
cdnBaseUrl?: string;
};
export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> & BrowserSpecificOptions;

/**
* The Sentry Browser SDK Client.
@@ -75,11 +79,7 @@ export class BrowserClient extends Client<BrowserClientOptions> {
* @param options Configuration options for this SDK.
*/
public constructor(options: BrowserClientOptions) {
const opts = {
// We default this to true, as it is the safer scenario
parentSpanIsAlwaysRootSpan: true,
...options,
};
const opts = applyDefaultOptions(options);
const sdkSource = WINDOW.SENTRY_SDK_SOURCE || getSDKSource();
applySdkMetadata(opts, 'browser', ['browser'], sdkSource);

@@ -155,3 +155,17 @@ export class BrowserClient extends Client<BrowserClientOptions> {
return super._prepareEvent(event, hint, currentScope, isolationScope);
}
}

/** Exported only for tests. */
export function applyDefaultOptions<T extends Partial<BrowserClientOptions>>(optionsArg: T): T {
return {
release:
typeof __SENTRY_RELEASE__ === 'string' // This allows build tooling to find-and-replace __SENTRY_RELEASE__ to inject a release value
? __SENTRY_RELEASE__
: WINDOW.SENTRY_RELEASE?.id, // This supports the variable that sentry-webpack-plugin injects
sendClientReports: true,
// We default this to true, as it is the safer scenario
parentSpanIsAlwaysRootSpan: true,
...optionsArg,
};
}
116 changes: 10 additions & 106 deletions packages/browser/src/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import type { Client, Integration, Options } from '@sentry/core';
import {
consoleSandbox,
dedupeIntegration,
functionToStringIntegration,
getIntegrationsToSetup,
getLocationHref,
inboundFiltersIntegration,
initAndBind,
stackParserFromStackParserOptions,
} from '@sentry/core';
import type { BrowserClientOptions, BrowserOptions } from './client';
import { BrowserClient } from './client';
import { DEBUG_BUILD } from './debug-build';
import { WINDOW } from './helpers';
import { breadcrumbsIntegration } from './integrations/breadcrumbs';
import { browserApiErrorsIntegration } from './integrations/browserapierrors';
import { browserSessionIntegration } from './integrations/browsersession';
@@ -21,22 +17,7 @@ import { httpContextIntegration } from './integrations/httpcontext';
import { linkedErrorsIntegration } from './integrations/linkederrors';
import { defaultStackParser } from './stack-parsers';
import { makeFetchTransport } from './transports/fetch';

type ExtensionProperties = {
chrome?: Runtime;
browser?: Runtime;
nw?: unknown;
};
type Runtime = {
runtime?: {
id?: string;
};
};

/**
* A magic string that build tooling can leverage in order to inject a release value into the SDK.
*/
declare const __SENTRY_RELEASE__: string | undefined;
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';

/** Get the default integrations for the browser SDK. */
export function getDefaultIntegrations(_options: Options): Integration[] {
@@ -59,40 +40,6 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
];
}

/** Exported only for tests. */
export function applyDefaultOptions(optionsArg: BrowserOptions = {}): BrowserOptions {
const defaultOptions: BrowserOptions = {
defaultIntegrations: getDefaultIntegrations(optionsArg),
release:
typeof __SENTRY_RELEASE__ === 'string' // This allows build tooling to find-and-replace __SENTRY_RELEASE__ to inject a release value
? __SENTRY_RELEASE__
: WINDOW.SENTRY_RELEASE?.id, // This supports the variable that sentry-webpack-plugin injects
sendClientReports: true,
};

return {
...defaultOptions,
...dropTopLevelUndefinedKeys(optionsArg),
};
}

/**
* In contrast to the regular `dropUndefinedKeys` method,
* this one does not deep-drop keys, but only on the top level.
*/
function dropTopLevelUndefinedKeys<T extends object>(obj: T): Partial<T> {
const mutatetedObj: Partial<T> = {};

for (const k of Object.getOwnPropertyNames(obj)) {
const key = k as keyof T;
if (obj[key] !== undefined) {
mutatetedObj[key] = obj[key];
}
}

return mutatetedObj;
}

/**
* The Sentry Browser SDK Client.
*
@@ -139,19 +86,21 @@ function dropTopLevelUndefinedKeys<T extends object>(obj: T): Partial<T> {
*
* @see {@link BrowserOptions} for documentation on configuration options.
*/
export function init(browserOptions: BrowserOptions = {}): Client | undefined {
if (!browserOptions.skipBrowserExtensionCheck && _checkForBrowserExtension()) {
return;
}
export function init(options: BrowserOptions = {}): Client | undefined {
const shouldDisableBecauseIsBrowserExtenstion =
!options.skipBrowserExtensionCheck && checkAndWarnIfIsEmbeddedBrowserExtension();

const options = applyDefaultOptions(browserOptions);
const clientOptions: BrowserClientOptions = {
...options,
enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled,
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations: getIntegrationsToSetup(options),
integrations: getIntegrationsToSetup({
integrations: options.integrations,
defaultIntegrations:
options.defaultIntegrations == null ? getDefaultIntegrations(options) : options.defaultIntegrations,
}),
transport: options.transport || makeFetchTransport,
};

return initAndBind(BrowserClient, clientOptions);
}

@@ -170,48 +119,3 @@ export function forceLoad(): void {
export function onLoad(callback: () => void): void {
callback();
}

function _isEmbeddedBrowserExtension(): boolean {
if (typeof WINDOW.window === 'undefined') {
// No need to show the error if we're not in a browser window environment (e.g. service workers)
return false;
}

const _window = WINDOW as typeof WINDOW & ExtensionProperties;

// Running the SDK in NW.js, which appears like a browser extension but isn't, is also fine
// see: https://github.com/getsentry/sentry-javascript/issues/12668
if (_window.nw) {
return false;
}

const extensionObject = _window['chrome'] || _window['browser'];

if (!extensionObject?.runtime?.id) {
return false;
}

const href = getLocationHref();
const extensionProtocols = ['chrome-extension', 'moz-extension', 'ms-browser-extension', 'safari-web-extension'];

// Running the SDK in a dedicated extension page and calling Sentry.init is fine; no risk of data leakage
const isDedicatedExtensionPage =
WINDOW === WINDOW.top && extensionProtocols.some(protocol => href.startsWith(`${protocol}://`));

return !isDedicatedExtensionPage;
}

function _checkForBrowserExtension(): true | void {
if (_isEmbeddedBrowserExtension()) {
if (DEBUG_BUILD) {
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.error(
'[Sentry] You cannot use Sentry.init() in a browser extension, see: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/',
);
});
}

return true;
}
}
65 changes: 65 additions & 0 deletions packages/browser/src/utils/detectBrowserExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { consoleSandbox, getLocationHref } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../helpers';

type ExtensionRuntime = {
runtime?: {
id?: string;
};
};
type ExtensionProperties = {
chrome?: ExtensionRuntime;
browser?: ExtensionRuntime;
nw?: unknown;
};

/**
* Returns true if the SDK is running in an embedded browser extension.
* Stand-alone browser extensions (which do not share the same data as the main browser page) are fine.
*/
export function checkAndWarnIfIsEmbeddedBrowserExtension(): boolean {
if (_isEmbeddedBrowserExtension()) {
if (DEBUG_BUILD) {
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.error(
'[Sentry] You cannot use Sentry.init() in a browser extension, see: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/',
);
});
}

return true;
}

return false;
}

function _isEmbeddedBrowserExtension(): boolean {
if (typeof WINDOW.window === 'undefined') {
// No need to show the error if we're not in a browser window environment (e.g. service workers)
return false;
}

const _window = WINDOW as typeof WINDOW & ExtensionProperties;

// Running the SDK in NW.js, which appears like a browser extension but isn't, is also fine
// see: https://github.com/getsentry/sentry-javascript/issues/12668
if (_window.nw) {
return false;
}

const extensionObject = _window['chrome'] || _window['browser'];

if (!extensionObject?.runtime?.id) {
return false;
}

const href = getLocationHref();
const extensionProtocols = ['chrome-extension', 'moz-extension', 'ms-browser-extension', 'safari-web-extension'];

// Running the SDK in a dedicated extension page and calling Sentry.init is fine; no risk of data leakage
const isDedicatedExtensionPage =
WINDOW === WINDOW.top && extensionProtocols.some(protocol => href.startsWith(`${protocol}://`));

return !isDedicatedExtensionPage;
}
69 changes: 68 additions & 1 deletion packages/browser/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@

import * as sentryCore from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BrowserClient } from '../src/client';
import { applyDefaultOptions, BrowserClient } from '../src/client';
import { WINDOW } from '../src/helpers';
import { getDefaultBrowserClientOptions } from './helper/browser-client-options';

@@ -118,3 +118,70 @@ describe('BrowserClient', () => {
});
});
});

describe('applyDefaultOptions', () => {
it('works with empty options', () => {
const options = {};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
release: undefined,
sendClientReports: true,
parentSpanIsAlwaysRootSpan: true,
});
});

it('works with options', () => {
const options = {
tracesSampleRate: 0.5,
release: '1.0.0',
};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
release: '1.0.0',
sendClientReports: true,
tracesSampleRate: 0.5,
parentSpanIsAlwaysRootSpan: true,
});
});

it('picks up release from WINDOW.SENTRY_RELEASE.id', () => {
const releaseBefore = WINDOW.SENTRY_RELEASE;

WINDOW.SENTRY_RELEASE = { id: '1.0.0' };
const options = {
tracesSampleRate: 0.5,
};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
release: '1.0.0',
sendClientReports: true,
tracesSampleRate: 0.5,
parentSpanIsAlwaysRootSpan: true,
});

WINDOW.SENTRY_RELEASE = releaseBefore;
});

it('passed in release takes precedence over WINDOW.SENTRY_RELEASE.id', () => {
const releaseBefore = WINDOW.SENTRY_RELEASE;

WINDOW.SENTRY_RELEASE = { id: '1.0.0' };
const options = {
release: '2.0.0',
tracesSampleRate: 0.5,
};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
release: '2.0.0',
sendClientReports: true,
tracesSampleRate: 0.5,
parentSpanIsAlwaysRootSpan: true,
});

WINDOW.SENTRY_RELEASE = releaseBefore;
});
});
120 changes: 10 additions & 110 deletions packages/browser/test/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -7,10 +7,10 @@ import type { Integration } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import { createTransport, resolvedSyncPromise } from '@sentry/core';
import type { Mock } from 'vitest';
import { afterAll, afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import { afterEach, describe, expect, it, test, vi } from 'vitest';
import type { BrowserOptions } from '../src';
import { WINDOW } from '../src';
import { applyDefaultOptions, getDefaultIntegrations, init } from '../src/sdk';
import { init } from '../src/sdk';

const PUBLIC_DSN = 'https://username@domain/123';

@@ -32,15 +32,11 @@ export class MockIntegration implements Integration {
}

describe('init', () => {
beforeEach(() => {
vi.clearAllMocks();
afterEach(() => {
vi.restoreAllMocks();
});

afterAll(() => {
vi.resetAllMocks();
});

test('installs default integrations', () => {
test('installs passed default integrations', () => {
const DEFAULT_INTEGRATIONS: Integration[] = [
new MockIntegration('MockIntegration 0.1'),
new MockIntegration('MockIntegration 0.2'),
@@ -134,7 +130,7 @@ describe('init', () => {
Object.defineProperty(WINDOW, 'browser', { value: undefined, writable: true });
Object.defineProperty(WINDOW, 'nw', { value: undefined, writable: true });
Object.defineProperty(WINDOW, 'window', { value: WINDOW, writable: true });
vi.clearAllMocks();
vi.restoreAllMocks();
});

it('logs a browser extension error if executed inside a Chrome extension', () => {
@@ -151,8 +147,6 @@ describe('init', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Sentry] You cannot use Sentry.init() in a browser extension, see: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/',
);

consoleErrorSpy.mockRestore();
});

it('logs a browser extension error if executed inside a Firefox/Safari extension', () => {
@@ -166,8 +160,6 @@ describe('init', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Sentry] You cannot use Sentry.init() in a browser extension, see: https://docs.sentry.io/platforms/javascript/best-practices/browser-extensions/',
);

consoleErrorSpy.mockRestore();
});

it.each(['chrome-extension', 'moz-extension', 'ms-browser-extension', 'safari-web-extension'])(
@@ -224,7 +216,7 @@ describe('init', () => {
consoleErrorSpy.mockRestore();
});

it("doesn't return a client on initialization error", () => {
it('returns a disabled client on initialization error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

Object.defineProperty(WINDOW, 'chrome', {
@@ -234,7 +226,9 @@ describe('init', () => {

const client = init(options);

expect(client).toBeUndefined();
expect(client).toBeDefined();
expect(SentryCore.isEnabled()).toBe(false);
expect(client!['_isEnabled']()).toBe(false);

consoleErrorSpy.mockRestore();
});
@@ -245,97 +239,3 @@ describe('init', () => {
expect(client).not.toBeUndefined();
});
});

describe('applyDefaultOptions', () => {
test('it works with empty options', () => {
const options = {};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
defaultIntegrations: expect.any(Array),
release: undefined,
sendClientReports: true,
});

expect((actual.defaultIntegrations as { name: string }[]).map(i => i.name)).toEqual(
getDefaultIntegrations(options).map(i => i.name),
);
});

test('it works with options', () => {
const options = {
tracesSampleRate: 0.5,
release: '1.0.0',
};
const actual = applyDefaultOptions(options);

expect(actual).toEqual({
defaultIntegrations: expect.any(Array),
release: '1.0.0',
sendClientReports: true,
tracesSampleRate: 0.5,
});

expect((actual.defaultIntegrations as { name: string }[]).map(i => i.name)).toEqual(
getDefaultIntegrations(options).map(i => i.name),
);
});

test('it works with defaultIntegrations=false', () => {
const options = {
defaultIntegrations: false,
} as const;
const actual = applyDefaultOptions(options);

expect(actual.defaultIntegrations).toStrictEqual(false);
});

test('it works with defaultIntegrations=[]', () => {
const options = {
defaultIntegrations: [],
};
const actual = applyDefaultOptions(options);

expect(actual.defaultIntegrations).toEqual([]);
});

test('it works with tracesSampleRate=undefined', () => {
const options = {
tracesSampleRate: undefined,
} as const;
const actual = applyDefaultOptions(options);

// Not defined, not even undefined
expect('tracesSampleRate' in actual).toBe(false);
});

test('it works with tracesSampleRate=null', () => {
const options = {
tracesSampleRate: null,
} as any;
const actual = applyDefaultOptions(options);

expect(actual.tracesSampleRate).toStrictEqual(null);
});

test('it works with tracesSampleRate=0', () => {
const options = {
tracesSampleRate: 0,
} as const;
const actual = applyDefaultOptions(options);

expect(actual.tracesSampleRate).toStrictEqual(0);
});

test('it does not deep-drop undefined keys', () => {
const options = {
obj: {
prop: undefined,
},
} as any;
const actual = applyDefaultOptions(options) as any;

expect('prop' in actual.obj).toBe(true);
expect(actual.obj.prop).toStrictEqual(undefined);
});
});