diff --git a/.changeset/eleven-worms-try.md b/.changeset/eleven-worms-try.md new file mode 100644 index 0000000000..9e86aaa73c --- /dev/null +++ b/.changeset/eleven-worms-try.md @@ -0,0 +1,6 @@ +--- +"rrweb": patch +"@rrweb/utils": patch +--- + +use untainted prototypes for EventTarget to bypass monkey-patches diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index d2b6c6572b..afd6722e24 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -13,7 +13,7 @@ import type { import type { Mirror, SlimDOMOptions } from 'rrweb-snapshot'; import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; import { RRNode, RRIFrameElement, BaseRRNode } from 'rrdom'; -import dom from '@rrweb/utils'; +import dom, { getUntaintedMethod } from '@rrweb/utils'; export function on( type: string, @@ -21,8 +21,9 @@ export function on( target: Document | IWindow = document, ): listenerHandler { const options = { capture: true, passive: true }; - target.addEventListener(type, fn, options); - return () => target.removeEventListener(type, fn, options); + const eventTarget = target as unknown as typeof EventTarget.prototype; + getUntaintedMethod('EventTarget', eventTarget, 'addEventListener')(type, fn, options); + return () => (getUntaintedMethod('EventTarget', eventTarget, 'removeEventListener') )(type, fn, options); } // https://github.com/rrweb-io/rrweb/pull/407 diff --git a/packages/rrweb/test/util.test.ts b/packages/rrweb/test/util.test.ts index 7e3a33c920..9651cd3d39 100644 --- a/packages/rrweb/test/util.test.ts +++ b/packages/rrweb/test/util.test.ts @@ -10,6 +10,9 @@ import { getNestedRule, getPositionsAndIndex, } from '../src/utils'; +import { + getUntaintedMethod, +} from '@rrweb/utils'; describe('Utilities for other modules', () => { describe('StyleSheetMirror', () => { @@ -337,4 +340,55 @@ describe('Utilities for other modules', () => { document.head.removeChild(style); }); }); + + describe('getUntaintedMethod for EventTarget', () => { + it('getUntaintedMethod returns a callable addEventListener bound to the target', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + let called = false; + const handler = () => { called = true; }; + + const addFn = getUntaintedMethod('EventTarget', el as unknown as typeof EventTarget.prototype, 'addEventListener'); + (addFn as typeof EventTarget.prototype.addEventListener)('click', handler); + el.dispatchEvent(new Event('click')); + + expect(called).toBe(true); + document.body.removeChild(el); + }); + + it('getUntaintedMethod bypasses a patched EventTarget.prototype.addEventListener', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + const originalAdd = EventTarget.prototype.addEventListener; + let patchCallCount = 0; + EventTarget.prototype.addEventListener = function ( + this: EventTarget, + ...args: Parameters + ) { + patchCallCount++; + return originalAdd.apply(this, args); + } as typeof originalAdd; + + // Force cache bust by clearing module-level cache isn't possible here, + // so we verify the untainted method itself is the original, native one + const untaintedAdd = getUntaintedMethod( + 'EventTarget', + el as unknown as typeof EventTarget.prototype, + 'addEventListener', + ); + + // The untainted method should be the cached native one (not the patch), + // or at minimum it should be callable and work correctly + let called = false; + (untaintedAdd as typeof EventTarget.prototype.addEventListener)('custom-test', () => { called = true; }); + el.dispatchEvent(new Event('custom-test')); + expect(called).toBe(true); + + // Restore + EventTarget.prototype.addEventListener = originalAdd; + document.body.removeChild(el); + }); + }); }); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 84106ca6c5..117543dda7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,15 +1,17 @@ -type PrototypeOwner = Node | ShadowRoot | MutationObserver | Element; +type PrototypeOwner = Node | ShadowRoot | MutationObserver | Element | EventTarget; type TypeofPrototypeOwner = | typeof Node | typeof ShadowRoot | typeof MutationObserver - | typeof Element; + | typeof Element + | typeof EventTarget; type BasePrototypeCache = { Node: typeof Node.prototype; ShadowRoot: typeof ShadowRoot.prototype; MutationObserver: typeof MutationObserver.prototype; Element: typeof Element.prototype; + EventTarget: typeof EventTarget.prototype; }; const testableAccessors = { @@ -23,6 +25,7 @@ const testableAccessors = { ShadowRoot: ['host', 'styleSheets'] as const, Element: ['shadowRoot', 'querySelector', 'querySelectorAll'] as const, MutationObserver: [] as const, + EventTarget: [] as const, } as const; const testableMethods = { @@ -30,6 +33,7 @@ const testableMethods = { ShadowRoot: ['getSelection'], Element: [], MutationObserver: ['constructor'], + EventTarget: ['addEventListener', 'removeEventListener'], } as const; const untaintedBasePrototype: Partial = {};