diff --git a/__tests__/components/providers/WagmiSetup.test.tsx b/__tests__/components/providers/WagmiSetup.test.tsx index a4c4288dfb..68022b8439 100644 --- a/__tests__/components/providers/WagmiSetup.test.tsx +++ b/__tests__/components/providers/WagmiSetup.test.tsx @@ -38,9 +38,6 @@ jest.mock("@capacitor/core", () => ({ isNativePlatform: jest.fn(() => false), }, })); -jest.mock("@/constants", () => ({ - CW_PROJECT_ID: "test-project-id", -})); jest.mock("@/utils/appkit-initialization.utils", () => ({ initializeAppKit: jest.fn().mockReturnValue({ adapter: { @@ -105,15 +102,60 @@ jest.mock("ethers", () => ({ describe("WagmiSetup Security Tests", () => { let mockInitializeAppKit: jest.Mock; + let mockLogErrorSecurely: jest.Mock; let mockSetToast: jest.Mock; let mockAdapterCreateMethod: jest.Mock; let mockUseAppWallets: jest.Mock; const MockAppKitAdapterManager = require("@/components/providers/AppKitAdapterManager").AppKitAdapterManager; + const originalEthereumDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "ethereum" + ); + const originalGlobalPrototype = Object.getPrototypeOf(globalThis); + const originalObjectGetOwnPropertyDescriptor = + Object.getOwnPropertyDescriptor; + const originalSafeEthereumProxyInstalled = ( + globalThis as { + __6529_safeEthereumProxyInstalled?: boolean | undefined; + } + ).__6529_safeEthereumProxyInstalled; + + const restoreGlobalPrototype = () => { + if (Object.getPrototypeOf(globalThis) !== originalGlobalPrototype) { + Object.setPrototypeOf(globalThis, originalGlobalPrototype); + } + }; + + const restoreEthereumState = () => { + restoreGlobalPrototype(); + + if (originalEthereumDescriptor) { + Object.defineProperty(globalThis, "ethereum", originalEthereumDescriptor); + } else { + delete (globalThis as { ethereum?: unknown }).ethereum; + } + + if (originalSafeEthereumProxyInstalled === undefined) { + delete ( + globalThis as { + __6529_safeEthereumProxyInstalled?: boolean | undefined; + } + ).__6529_safeEthereumProxyInstalled; + return; + } + + ( + globalThis as { + __6529_safeEthereumProxyInstalled?: boolean | undefined; + } + ).__6529_safeEthereumProxyInstalled = originalSafeEthereumProxyInstalled; + }; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + restoreEthereumState(); // Add unhandled rejection handler for expected errors globalThis.addEventListener("unhandledrejection", (event) => { @@ -126,6 +168,7 @@ describe("WagmiSetup Security Tests", () => { mockInitializeAppKit = require("@/utils/appkit-initialization.utils").initializeAppKit; + mockLogErrorSecurely = require("@/utils/error-sanitizer").logErrorSecurely; mockSetToast = jest.fn(); mockAdapterCreateMethod = jest.fn(); mockUseAppWallets = useAppWallets as jest.Mock; @@ -190,6 +233,7 @@ describe("WagmiSetup Security Tests", () => { afterEach(() => { jest.useRealTimers(); + restoreEthereumState(); // Restore console.error (console.error as jest.Mock).mockRestore?.(); }); @@ -223,6 +267,297 @@ describe("WagmiSetup Security Tests", () => { }); }); + describe("Ethereum Proxy Installation", () => { + const readOnlyEthereumLogContext = + "[WagmiSetup] Skipping safe ethereum proxy install for read-only window.ethereum"; + + it("installs the proxy when window.ethereum is configurable getter-only", async () => { + const provider = { + request() { + return this; + }, + }; + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + get: () => provider, + }); + + await renderAndWaitForMount(); + + const proxiedEthereum = (globalThis as { ethereum?: any }).ethereum; + const proxiedEthereumDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "ethereum" + ); + + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect(proxiedEthereum).toBeDefined(); + expect(proxiedEthereum).not.toBe(provider); + expect(proxiedEthereum.request()).toBe(provider); + expect(proxiedEthereumDescriptor?.configurable).toBe(true); + expect(proxiedEthereumDescriptor?.writable).toBe(true); + expect(proxiedEthereumDescriptor?.value).toBe(proxiedEthereum); + expect(mockLogErrorSecurely).not.toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + const safeEthereumProxyInstalled = ( + globalThis as { + __6529_safeEthereumProxyInstalled?: boolean | undefined; + } + ).__6529_safeEthereumProxyInstalled; + expect(safeEthereumProxyInstalled).toBe(true); + }); + + it("skips proxy installation when own window.ethereum is non-configurable getter-only", async () => { + const provider = { + request() { + return this; + }, + }; + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + get: () => provider, + }); + const getOwnPropertyDescriptorSpy = jest + .spyOn(Object, "getOwnPropertyDescriptor") + .mockImplementation((target, property) => { + if (target === globalThis && property === "ethereum") { + return { + configurable: false, + enumerable: true, + get: () => provider, + }; + } + + return originalObjectGetOwnPropertyDescriptor(target, property); + }); + + try { + await renderAndWaitForMount(); + + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect((globalThis as { ethereum?: unknown }).ethereum).toBe(provider); + expect(mockLogErrorSecurely).toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + expect( + ( + globalThis as { + __6529_safeEthereumProxyInstalled?: boolean | undefined; + } + ).__6529_safeEthereumProxyInstalled + ).toBe(true); + } finally { + getOwnPropertyDescriptorSpy.mockRestore(); + } + }); + + it("installs the proxy when window.ethereum is inherited non-configurable getter-only", async () => { + const provider = { + request() { + return this; + }, + }; + const prototypeWithEthereum = Object.create( + Object.getPrototypeOf(globalThis) + ); + + Object.defineProperty(prototypeWithEthereum, "ethereum", { + configurable: false, + get: () => provider, + }); + Object.setPrototypeOf(globalThis, prototypeWithEthereum); + + await renderAndWaitForMount(); + + const proxiedEthereum = (globalThis as { ethereum?: any }).ethereum; + const proxiedEthereumDescriptor = originalObjectGetOwnPropertyDescriptor( + globalThis, + "ethereum" + ); + + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect(proxiedEthereum).toBeDefined(); + expect(proxiedEthereum).not.toBe(provider); + expect(proxiedEthereum.request()).toBe(provider); + expect(proxiedEthereumDescriptor?.configurable).toBe(true); + expect(proxiedEthereumDescriptor?.writable).toBe(true); + expect(proxiedEthereumDescriptor?.value).toBe(proxiedEthereum); + expect(mockLogErrorSecurely).not.toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + }); + + it("installs the proxy when own window.ethereum is non-configurable writable", async () => { + const provider = { + request() { + return this; + }, + }; + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + writable: true, + value: provider, + }); + + const getOwnPropertyDescriptorSpy = jest + .spyOn(Object, "getOwnPropertyDescriptor") + .mockImplementation((target, property) => { + if (target === globalThis && property === "ethereum") { + return { + configurable: false, + enumerable: true, + writable: true, + value: provider, + }; + } + + return originalObjectGetOwnPropertyDescriptor(target, property); + }); + + try { + await renderAndWaitForMount(); + + const proxiedEthereum = (globalThis as { ethereum?: any }).ethereum; + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect(proxiedEthereum).toBeDefined(); + expect(proxiedEthereum).not.toBe(provider); + expect(proxiedEthereum.request()).toBe(provider); + expect(mockLogErrorSecurely).not.toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + } finally { + getOwnPropertyDescriptorSpy.mockRestore(); + } + }); + + it("installs the proxy when own window.ethereum is non-configurable accessor with setter", async () => { + const provider = { + request() { + return this; + }, + }; + let currentEthereum: unknown = provider; + const setEthereum = jest.fn((value: unknown) => { + currentEthereum = value; + }); + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + enumerable: true, + get: () => currentEthereum, + set: setEthereum, + }); + + const getOwnPropertyDescriptorSpy = jest + .spyOn(Object, "getOwnPropertyDescriptor") + .mockImplementation((target, property) => { + if (target === globalThis && property === "ethereum") { + return { + configurable: false, + enumerable: true, + get: () => currentEthereum, + set: setEthereum, + }; + } + + return originalObjectGetOwnPropertyDescriptor(target, property); + }); + + try { + await renderAndWaitForMount(); + + const proxiedEthereum = (globalThis as { ethereum?: any }).ethereum; + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect(setEthereum).toHaveBeenCalledTimes(1); + expect(currentEthereum).toBe(proxiedEthereum); + expect(proxiedEthereum).toBeDefined(); + expect(proxiedEthereum).not.toBe(provider); + expect(proxiedEthereum.request()).toBe(provider); + expect(mockLogErrorSecurely).not.toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + } finally { + getOwnPropertyDescriptorSpy.mockRestore(); + } + }); + + it("installs the proxy when window.ethereum is writable", async () => { + const provider = { + request() { + return this; + }, + }; + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + writable: true, + value: provider, + }); + + await renderAndWaitForMount(); + + const proxiedEthereum = (globalThis as { ethereum?: any }).ethereum; + expect(mockInitializeAppKit).toHaveBeenCalled(); + expect(proxiedEthereum).toBeDefined(); + expect(proxiedEthereum).not.toBe(provider); + expect(proxiedEthereum.request()).toBe(provider); + expect(mockLogErrorSecurely).not.toHaveBeenCalledWith( + readOnlyEthereumLogContext, + expect.any(Error) + ); + }); + + it("logs the read-only ethereum skip once across mounts for own non-configurable getter-only descriptors", async () => { + const provider = { + request() { + return this; + }, + }; + + Object.defineProperty(globalThis, "ethereum", { + configurable: true, + get: () => provider, + }); + + const getOwnPropertyDescriptorSpy = jest + .spyOn(Object, "getOwnPropertyDescriptor") + .mockImplementation((target, property) => { + if (target === globalThis && property === "ethereum") { + return { + configurable: false, + enumerable: true, + get: () => provider, + }; + } + + return originalObjectGetOwnPropertyDescriptor(target, property); + }); + + try { + const firstRender = await renderAndWaitForMount(); + firstRender.unmount(); + + await renderAndWaitForMount(); + + const readOnlyLogs = mockLogErrorSecurely.mock.calls.filter( + ([context]) => context === readOnlyEthereumLogContext + ); + expect(readOnlyLogs).toHaveLength(1); + } finally { + getOwnPropertyDescriptorSpy.mockRestore(); + } + }); + }); + // Note: Error handling tests removed due to implementation bug // The useEffect doesn't await initializeAppKit() causing unhandled promise rejections // when the function throws after calling setToast. This is an implementation bug. diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index db68ccf538..268c0a00ab 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -1,9 +1,78 @@ import { __testing, shouldFilterByFilenameExceptions, + shouldFilterInjectedWalletCollision, + shouldFilterTwitterConfigReferenceError, } from "@/utils/sentry-client-filters"; describe("sentry-client-filters", () => { + const createTwitterConfigEvent = (overrides: Record = {}) => + ({ + exception: { + values: [ + { + type: "ReferenceError", + value: "Can't find variable: CONFIG", + stacktrace: { + frames: [ + { filename: "app:///", abs_path: "app:///" }, + { filename: "app:///", abs_path: "app:///" }, + ], + }, + }, + ], + }, + contexts: { + browser: { + name: "Twitter", + }, + }, + tags: { + browser: "Twitter 11.62", + "browser.name": "Twitter", + }, + ...overrides, + }) as any; + + const createInjectedWalletCollisionEvent = ( + overrides: Record = {} + ) => + ({ + exception: { + values: [ + { + type: "TypeError", + value: + "'set' on proxy: trap returned falsish for property 'tronlinkParams'", + stacktrace: { + frames: [ + { + filename: "app:///injected/injected.js", + abs_path: "app:///injected/injected.js", + }, + ], + }, + }, + ], + }, + breadcrumbs: { + values: [ + { + category: "console", + message: + "[WagmiSetup] Failed to install safe ethereum proxy Error: Cannot set property ethereum of # which has only a getter", + data: { + arguments: [ + "[WagmiSetup] Failed to install safe ethereum proxy Error:", + "Cannot set property ethereum of # which has only a getter", + ], + }, + }, + ], + }, + ...overrides, + }) as any; + it("filters events when a stack frame matches a filename exception", () => { // Arrange const frames = [{ filename: "app:///extensionServiceWorker.js" } as any]; @@ -58,6 +127,22 @@ describe("sentry-client-filters", () => { expect(result).toBe(true); }); + it("filters events when only abs_path matches a filename exception", () => { + // Arrange + const frames = [ + { + filename: "https://example.com/main.js", + abs_path: "chrome-extension://wallet/extensionServiceWorker.js", + }, + ] as any; + + // Act + const result = shouldFilterByFilenameExceptions(frames); + + // Assert + expect(result).toBe(true); + }); + it("does not filter when frames do not match any filename exception", () => { // Arrange const frames = [{ filename: "app:///main.js" } as any]; @@ -104,4 +189,384 @@ describe("sentry-client-filters", () => { // Assert expect(result).toContain(expected); }); + + it("filters Twitter CONFIG reference errors with app URI frames", () => { + // Arrange + const event = createTwitterConfigEvent(); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(true); + }); + + it("does not filter CONFIG reference errors outside Twitter", () => { + // Arrange + const event = createTwitterConfigEvent({ + contexts: { browser: { name: "Safari" } }, + tags: { + browser: "Safari Mobile", + "browser.name": "Safari", + }, + }); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(false); + }); + + it("filters CONFIG reference errors when Twitter is present only in tags", () => { + // Arrange + const event = createTwitterConfigEvent({ + contexts: {}, + }); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions for tronlinkParams in app URI stacks", () => { + // Arrange + const event = createInjectedWalletCollisionEvent(); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions from breadcrumb-only ethereum getter errors", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Some wrapper error", + stacktrace: { + frames: [ + { + filename: "app:///injected/injected.js", + abs_path: "app:///injected/injected.js", + }, + ], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions when breadcrumbs are in Sentry array form", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Some wrapper error", + stacktrace: { + frames: [ + { + filename: "app:///injected/injected.js", + abs_path: "app:///injected/injected.js", + }, + ], + }, + }, + ], + }, + breadcrumbs: [ + { + category: "console", + message: + "Cannot set property ethereum of # which has only a getter", + }, + ], + }); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions from the original exception stack", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Some wrapper error", + }, + ], + }, + breadcrumbs: { + values: [], + }, + }); + const error = new Error( + "Cannot set property ethereum of # which has only a getter" + ); + error.stack = `TypeError: Cannot set property ethereum of # which has only a getter\n at injected (app:///injected/injected.js:1:1)`; + + // Act + const result = shouldFilterInjectedWalletCollision(event, { + originalException: error, + }); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions when stack frames are empty", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Some wrapper error", + stacktrace: { + frames: [], + }, + }, + ], + }, + breadcrumbs: { + values: [], + }, + }); + const error = new Error( + "Cannot set property ethereum of # which has only a getter" + ); + error.stack = `TypeError: Cannot set property ethereum of # which has only a getter\n at injected (app:///injected/injected.js:1:1)`; + + // Act + const result = shouldFilterInjectedWalletCollision(event, { + originalException: error, + }); + + // Assert + expect(result).toBe(true); + }); + + it("filters injected wallet collisions when only abs_path has the injected app URI", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "'set' on proxy: trap returned falsish for property 'tronlinkParams'", + stacktrace: { + frames: [ + { + filename: "https://example.com/injected.js", + abs_path: "app:///injected/injected.js", + }, + ], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(true); + }); + + it("does not filter injected wallet collisions when a web frame is present", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "'set' on proxy: trap returned falsish for property 'tronlinkParams'", + stacktrace: { + frames: [ + { + filename: "app:///injected/injected.js", + abs_path: "app:///injected/injected.js", + }, + { + filename: "https://example.com/app.js", + abs_path: "https://example.com/app.js", + }, + ], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(false); + }); + + it("does not filter unrelated app URI injected errors", () => { + // Arrange + const event = createInjectedWalletCollisionEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Cannot read properties of undefined (reading 'foo')", + stacktrace: { + frames: [ + { + filename: "app:///injected/injected.js", + abs_path: "app:///injected/injected.js", + }, + ], + }, + }, + ], + }, + breadcrumbs: { + values: [ + { + category: "console", + message: "Random console error", + }, + ], + }, + }); + + // Act + const result = shouldFilterInjectedWalletCollision(event); + + // Assert + expect(result).toBe(false); + }); + + it("does not filter non-reference errors from Twitter", () => { + // Arrange + const event = createTwitterConfigEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Can't find variable: CONFIG", + stacktrace: { + frames: [{ filename: "app:///", abs_path: "app:///" }], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(false); + }); + + it("does not filter Twitter CONFIG errors when frames are not all app URIs", () => { + // Arrange + const event = createTwitterConfigEvent({ + exception: { + values: [ + { + type: "ReferenceError", + value: "Can't find variable: CONFIG", + stacktrace: { + frames: [ + { filename: "app:///", abs_path: "app:///" }, + { + filename: "https://example.com/main.js", + abs_path: "https://example.com/main.js", + }, + ], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(false); + }); + + it("does not filter unrelated app URI errors from Twitter", () => { + // Arrange + const event = createTwitterConfigEvent({ + exception: { + values: [ + { + type: "ReferenceError", + value: "Can't find variable: SOMETHING_ELSE", + stacktrace: { + frames: [{ filename: "app:///", abs_path: "app:///" }], + }, + }, + ], + }, + }); + + // Act + const result = shouldFilterTwitterConfigReferenceError(event); + + // Assert + expect(result).toBe(false); + }); + + it("detects app URI-only frame stacks in testing helpers", () => { + // Arrange + const frames = [{ filename: "app:///" }, { abs_path: "app:///" }] as any; + + // Act + const result = __testing.hasOnlyAppUriFrames(frames); + + // Assert + expect(result).toBe(true); + }); + + it("detects app URI-only frame stacks when only abs_path has the app URI", () => { + // Arrange + const frames = [ + { + filename: "https://example.com/main.js", + abs_path: "app:///main.js", + }, + { + filename: "app:///bootstrap.js", + abs_path: "https://example.com/bootstrap.js", + }, + ] as any; + + // Act + const result = __testing.hasOnlyAppUriFrames(frames); + + // Assert + expect(result).toBe(true); + }); }); diff --git a/components/providers/WagmiSetup.tsx b/components/providers/WagmiSetup.tsx index 0a5b10ca6e..e4847cacc7 100644 --- a/components/providers/WagmiSetup.tsx +++ b/components/providers/WagmiSetup.tsx @@ -59,6 +59,19 @@ function installSafeEthereumProxy(): void { return; } + const ownEthereumDescriptor = Object.getOwnPropertyDescriptor(w, "ethereum"); + if ( + ownEthereumDescriptor?.configurable === false && + !canAssignProperty(ownEthereumDescriptor) + ) { + logErrorSecurely( + "[WagmiSetup] Skipping safe ethereum proxy install for read-only window.ethereum", + new Error("window.ethereum cannot be reassigned") + ); + w.__6529_safeEthereumProxyInstalled = true; + return; + } + try { let hasLoggedProxyGetError = false; const proxy = new Proxy(ethereum, { @@ -84,7 +97,16 @@ function installSafeEthereumProxy(): void { }, }); - w.ethereum = proxy; + if (ownEthereumDescriptor?.configurable === false) { + w.ethereum = proxy; + } else { + Object.defineProperty(w, "ethereum", { + configurable: true, + enumerable: ownEthereumDescriptor?.enumerable ?? true, + writable: true, + value: proxy, + }); + } w.__6529_safeEthereumProxyInstalled = true; } catch (error) { logErrorSecurely( @@ -95,6 +117,14 @@ function installSafeEthereumProxy(): void { } } +function canAssignProperty(descriptor: PropertyDescriptor): boolean { + if ("get" in descriptor || "set" in descriptor) { + return typeof descriptor.set === "function"; + } + + return descriptor.writable !== false; +} + export default function WagmiSetup({ children, }: { diff --git a/instrumentation-client.ts b/instrumentation-client.ts index acc4624b64..e3b1529889 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -12,7 +12,11 @@ import { sanitizeSentryEvent, sanitizeUrlString, } from "@/utils/sentry-sanitizer"; -import { shouldFilterByFilenameExceptions } from "@/utils/sentry-client-filters"; +import { + shouldFilterByFilenameExceptions, + shouldFilterInjectedWalletCollision, + shouldFilterTwitterConfigReferenceError, +} from "@/utils/sentry-client-filters"; import * as Sentry from "@sentry/nextjs"; const sentryEnabled = !!publicEnv.SENTRY_DSN; @@ -72,6 +76,14 @@ function shouldFilterEvent( } } + if (shouldFilterInjectedWalletCollision(event, hint)) { + return true; + } + + if (shouldFilterTwitterConfigReferenceError(event)) { + return true; + } + const frames = event.exception?.values?.[0]?.stacktrace?.frames; return shouldFilterByFilenameExceptions(frames, hint); } @@ -185,7 +197,10 @@ Sentry.init({ getFallbackMessage(hint) || (typeof event.message === "string" ? event.message : ""); - if ((error && isIndexedDBError(error)) || (message && isIndexedDBError(message))) { + if ( + (error && isIndexedDBError(error)) || + (message && isIndexedDBError(message)) + ) { handleIndexedDBError(event); } diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 869d6283d7..7bc4f42920 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -2,9 +2,46 @@ export type SentryStackFrame = { filename?: string | undefined; abs_path?: string | undefined; }; + +type SentryContext = Record; + +type SentryBreadcrumb = { + category?: string | undefined; + message?: string | undefined; + data?: Record | undefined; +}; + +type SentryExceptionValue = { + type?: string | undefined; + value?: string | undefined; + stacktrace?: + | { + frames?: SentryStackFrame[] | undefined; + } + | undefined; +}; + +type SentryTags = Record; + +export type SentryClientEvent = { + exception?: + | { + values?: SentryExceptionValue[] | undefined; + } + | undefined; + contexts?: Record | undefined; + tags?: SentryTags | undefined; + breadcrumbs?: + | SentryBreadcrumb[] + | { + values?: SentryBreadcrumb[] | undefined; + } + | undefined; +}; + export type SentryEventHint = { - originalException?: unknown | undefined; - syntheticException?: unknown | undefined; + originalException?: unknown; + syntheticException?: unknown; }; const filenameExceptions = [ @@ -14,6 +51,14 @@ const filenameExceptions = [ "injectLeap.js", "inject.chrome", ]; +const injectedAppUriPath = "app:///injected/injected.js"; +const walletCollisionPatterns = [ + "tronlinkparams", + "cannot set property ethereum of # which has only a getter", + "cannot assign to read only property 'ethereum'", + 'cannot assign to read only property "ethereum"', + "cannot redefine property: ethereum", +]; function shouldFilterFilenameExceptions( frames: SentryStackFrame[] | undefined @@ -24,7 +69,7 @@ function shouldFilterFilenameExceptions( return frames.some((frame) => filenameExceptions.some( (pattern) => - frame?.filename?.includes(pattern) || frame?.abs_path?.includes(pattern) + frame.filename?.includes(pattern) || frame.abs_path?.includes(pattern) ) ); } @@ -41,6 +86,161 @@ function shouldFilterExceptionStack(hint?: SentryEventHint): boolean { return filenameExceptions.some((pattern) => stack.includes(pattern)); } +function isAppUriFrame(frame: SentryStackFrame): boolean { + return [frame.filename, frame.abs_path].some( + (path) => typeof path === "string" && path.startsWith("app:///") + ); +} + +function isInjectedAppUriFrame(frame: SentryStackFrame): boolean { + return [frame.filename, frame.abs_path].some( + (path) => typeof path === "string" && path.includes(injectedAppUriPath) + ); +} + +function hasOnlyAppUriFrames(frames: SentryStackFrame[] | undefined): boolean { + return ( + Array.isArray(frames) && frames.length > 0 && frames.every(isAppUriFrame) + ); +} + +function hasInjectedAppUriFrame( + frames: SentryStackFrame[] | undefined +): boolean { + return Array.isArray(frames) && frames.some(isInjectedAppUriFrame); +} + +function getHintException(hint?: SentryEventHint): unknown { + return hint?.originalException ?? hint?.syntheticException; +} + +function getHintExceptionMessage(hint?: SentryEventHint): string { + const exception = getHintException(hint); + if (typeof exception === "string") { + return exception; + } + if (exception instanceof Error) { + return exception.message; + } + return ""; +} + +function getHintExceptionStack(hint?: SentryEventHint): string { + const exception = getHintException(hint); + if (exception instanceof Error && typeof exception.stack === "string") { + return exception.stack; + } + return ""; +} + +function matchesWalletCollisionPattern(value: string): boolean { + const normalizedValue = value.toLowerCase(); + return walletCollisionPatterns.some((pattern) => + normalizedValue.includes(pattern) + ); +} + +function getBreadcrumbMessages(event: SentryClientEvent): string[] { + const breadcrumbs = getBreadcrumbValues(event); + return breadcrumbs.flatMap((breadcrumb) => { + const values: string[] = []; + if (typeof breadcrumb.message === "string") { + values.push(breadcrumb.message); + } + + const args = breadcrumb.data?.["arguments"]; + if (Array.isArray(args)) { + values.push( + ...args.filter((value): value is string => typeof value === "string") + ); + } + + return values; + }); +} + +function getBreadcrumbValues(event: SentryClientEvent): SentryBreadcrumb[] { + const breadcrumbs = event.breadcrumbs; + if (Array.isArray(breadcrumbs)) { + return breadcrumbs; + } + + if (Array.isArray(breadcrumbs?.values)) { + return breadcrumbs.values; + } + + return []; +} + +function getContextString( + event: SentryClientEvent, + contextKey: string, + valueKey: string +): string | undefined { + const context = event.contexts?.[contextKey]; + if (!context) { + return undefined; + } + + const value = context[valueKey]; + return typeof value === "string" ? value : undefined; +} + +function hasInjectedAppUriSignature( + frames: SentryStackFrame[] | undefined, + hint?: SentryEventHint +): boolean { + const hasOnlyInjectedFrames = + hasOnlyAppUriFrames(frames) && hasInjectedAppUriFrame(frames); + if (hasOnlyInjectedFrames) { + return true; + } + + const stack = getHintExceptionStack(hint); + if (!stack.includes(injectedAppUriPath)) { + return false; + } + + if (!Array.isArray(frames) || frames.length === 0) { + return true; + } + + return hasOnlyAppUriFrames(frames); +} + +function hasWalletCollisionSignature( + event: SentryClientEvent, + hint?: SentryEventHint +): boolean { + const value = event.exception?.values?.[0]; + const candidates = [ + value?.value, + getHintExceptionMessage(hint), + getHintExceptionStack(hint), + ...getBreadcrumbMessages(event), + ]; + + return candidates.some( + (candidate) => + typeof candidate === "string" && matchesWalletCollisionPattern(candidate) + ); +} + +function isTwitterBrowser(event: SentryClientEvent): boolean { + const contextBrowserName = getContextString(event, "browser", "name"); + if (contextBrowserName === "Twitter") { + return true; + } + + const browserNameTag = event.tags?.["browser.name"]; + if (browserNameTag === "Twitter") { + return true; + } + + const browserTag = event.tags?.["browser"]; + return typeof browserTag === "string" && browserTag.startsWith("Twitter"); +} + export function shouldFilterByFilenameExceptions( frames: SentryStackFrame[] | undefined, hint?: SentryEventHint @@ -50,4 +250,41 @@ export function shouldFilterByFilenameExceptions( ); } -export const __testing = { filenameExceptions }; +export function shouldFilterTwitterConfigReferenceError( + event: SentryClientEvent +): boolean { + const value = event.exception?.values?.[0]; + if (value?.type !== "ReferenceError") { + return false; + } + + if (value.value !== "Can't find variable: CONFIG") { + return false; + } + + if (!isTwitterBrowser(event)) { + return false; + } + + return hasOnlyAppUriFrames(value.stacktrace?.frames); +} + +export function shouldFilterInjectedWalletCollision( + event: SentryClientEvent, + hint?: SentryEventHint +): boolean { + const frames = event.exception?.values?.[0]?.stacktrace?.frames; + if (!hasInjectedAppUriSignature(frames, hint)) { + return false; + } + + return hasWalletCollisionSignature(event, hint); +} + +export const __testing = { + filenameExceptions, + hasOnlyAppUriFrames, + hasInjectedAppUriFrame, + isTwitterBrowser, + matchesWalletCollisionPattern, +};