Skip to content
Open
Show file tree
Hide file tree
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
6 changes: 6 additions & 0 deletions .changeset/eleven-worms-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"rrweb": patch
"@rrweb/utils": patch
---

use untainted prototypes for EventTarget to bypass monkey-patches
7 changes: 4 additions & 3 deletions packages/rrweb/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ 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,
fn: EventListenerOrEventListenerObject,
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);
Comment on lines +25 to +26
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup handler calls getUntaintedMethod(...) again and has an extra )( parenthesis/space. Consider grabbing both untainted methods once (e.g., const add = ...; const remove = ...;) and reusing them for add/remove to avoid repeated lookups/binds and to keep the return statement readable (also avoids formatting/lint issues).

Suggested change
getUntaintedMethod('EventTarget', eventTarget, 'addEventListener')(type, fn, options);
return () => (getUntaintedMethod('EventTarget', eventTarget, 'removeEventListener') )(type, fn, options);
const add = getUntaintedMethod('EventTarget', eventTarget, 'addEventListener');
const remove = getUntaintedMethod(
'EventTarget',
eventTarget,
'removeEventListener',
);
add(type, fn, options);
return () => remove(type, fn, options);

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +26
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast target as unknown as typeof EventTarget.prototype is confusing (it reads like a prototype object, but it’s actually an EventTarget instance). Using EventTarget (or EventTarget & { addEventListener: ... }) directly would make the intent clearer and avoid misleading types in future refactors.

Suggested change
const eventTarget = target as unknown as typeof EventTarget.prototype;
getUntaintedMethod('EventTarget', eventTarget, 'addEventListener')(type, fn, options);
return () => (getUntaintedMethod('EventTarget', eventTarget, 'removeEventListener') )(type, fn, options);
const eventTarget = target as unknown as EventTarget;
getUntaintedMethod('EventTarget', eventTarget, 'addEventListener')(type, fn, options);
return () => (getUntaintedMethod('EventTarget', eventTarget, 'removeEventListener'))(type, fn, options);

Copilot uses AI. Check for mistakes.
}

// https://github.com/rrweb-io/rrweb/pull/407
Expand Down
54 changes: 54 additions & 0 deletions packages/rrweb/test/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
getNestedRule,
getPositionsAndIndex,
} from '../src/utils';
import {
getUntaintedMethod,
} from '@rrweb/utils';

describe('Utilities for other modules', () => {
describe('StyleSheetMirror', () => {
Expand Down Expand Up @@ -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<typeof originalAdd>
) {
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',
);

Comment on lines +344 to +381
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test’s behavior depends on module-level caches inside @rrweb/utils: the first test in this describe block can populate the cache before the prototype is patched, so the “bypasses a patched ...” case may pass even if the initial cache fill would fail when patching happens first. Consider patching before the first getUntaintedMethod call (or using vi.resetModules() + dynamic import) so the test validates the real-world scenario where the prototype is already monkey-patched when rrweb starts.

Copilot uses AI. Check for mistakes.
// 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);
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test patches EventTarget.prototype.addEventListener but never asserts that the patched implementation was bypassed (e.g., patchCallCount stays 0). Adding an assertion on patchCallCount would make the test actually verify the intended behavior rather than only that events still fire.

Suggested change
expect(called).toBe(true);
expect(called).toBe(true);
expect(patchCallCount).toBe(0);

Copilot uses AI. Check for mistakes.

// Restore
EventTarget.prototype.addEventListener = originalAdd;
document.body.removeChild(el);
Comment on lines +374 to +391
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventTarget.prototype.addEventListener is restored only at the end of the test. If an expectation throws before the restore runs, the patched prototype can leak into later tests. Wrap the patch/restore in try/finally (or afterEach) to guarantee restoration.

Suggested change
// 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);
try {
// 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);
} finally {
EventTarget.prototype.addEventListener = originalAdd;
document.body.removeChild(el);
}

Copilot uses AI. Check for mistakes.
});
});
});
8 changes: 6 additions & 2 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +1 to +7
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding EventTarget as a supported prototype owner means getUntaintedPrototype('EventTarget') will attempt globalThis['EventTarget'].prototype. If EventTarget is missing in a given runtime, this will throw before the try/catch fallback runs. Consider guarding getUntaintedPrototype (e.g., return the default prototype or throw a clearer error) when globalThis[key] is undefined to avoid hard crashes.

Copilot uses AI. Check for mistakes.

type BasePrototypeCache = {
Node: typeof Node.prototype;
ShadowRoot: typeof ShadowRoot.prototype;
MutationObserver: typeof MutationObserver.prototype;
Element: typeof Element.prototype;
EventTarget: typeof EventTarget.prototype;
};

const testableAccessors = {
Expand All @@ -23,13 +25,15 @@ const testableAccessors = {
ShadowRoot: ['host', 'styleSheets'] as const,
Element: ['shadowRoot', 'querySelector', 'querySelectorAll'] as const,
MutationObserver: [] as const,
EventTarget: [] as const,
} as const;

const testableMethods = {
Node: ['contains', 'getRootNode'] as const,
ShadowRoot: ['getSelection'],
Element: [],
MutationObserver: ['constructor'],
EventTarget: ['addEventListener', 'removeEventListener'],
} as const;

const untaintedBasePrototype: Partial<BasePrototypeCache> = {};
Expand Down
Loading