From e66ddbafac0e348c066760a109a408dff861a1fa Mon Sep 17 00:00:00 2001 From: PostHog Code Date: Mon, 27 Apr 2026 12:19:12 +0000 Subject: [PATCH] fix(record): handle SecurityError on ownerDocument access In Firefox, reading `ownerDocument` on a node whose owning document has navigated cross-origin throws "Permission denied to access property 'ownerDocument'" (a SecurityError variant). The unguarded reads in `inDom`, `shadowHostInDom`, and `initAdoptedStyleSheetObserver` could abort the surrounding mutation/shadow-DOM observer callback, in the same family of failures fixed by 50bf563, e06b822, and a0e18f1 for `attachIframe`. - `inDom`/`shadowHostInDom`: read `ownerDocument` through a small helper that returns `null` if the access throws, so callers treat the node as not in the DOM rather than re-throwing. - `initAdoptedStyleSheetObserver`: wrap the `host.ownerDocument?.defaultView?.ShadowRoot` lookup and bail out with a no-op cleanup handler when a `SecurityError` is thrown. - Add regression tests covering both code paths with a node whose `ownerDocument` getter throws a `SecurityError`-named DOMException. Generated-By: PostHog Code Task-Id: 157b8b4a-321b-43ff-bb3b-dd519222e01e --- packages/rrweb/src/record/observer.ts | 21 +++++++-- packages/rrweb/src/utils.ts | 16 ++++++- packages/rrweb/test/record/observer.test.ts | 51 +++++++++++++++++++++ packages/rrweb/test/util.test.ts | 21 +++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 packages/rrweb/test/record/observer.test.ts diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 57e52e81..41e0f1e1 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -883,10 +883,23 @@ export function initAdoptedStyleSheetObserver( // The host is a ShadowRoot. else hostId = mirror.getId(dom.host(host as ShadowRoot)); - const patchTarget = - host.nodeName === '#document' - ? (host as Document).defaultView?.Document - : host.ownerDocument?.defaultView?.ShadowRoot; + // Reading `ownerDocument` on a ShadowRoot whose owning document has navigated + // cross-origin can throw a SecurityError in Firefox; bail out cleanly so the + // caller is not aborted. + let patchTarget: typeof Document | typeof ShadowRoot | undefined; + try { + patchTarget = + host.nodeName === '#document' + ? (host as Document).defaultView?.Document + : host.ownerDocument?.defaultView?.ShadowRoot; + } catch (error) { + if (!(error instanceof DOMException && error.name === 'SecurityError')) { + throw error; + } + return () => { + // + }; + } const originalPropertyDescriptor = patchTarget?.prototype ? Object.getOwnPropertyDescriptor( patchTarget?.prototype, diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index cc51e109..806a0e0b 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -551,15 +551,27 @@ export function getRootShadowHost(n: Node): Node { return rootShadowHost; } +// In Firefox, reading `ownerDocument` on a node whose document has navigated +// cross-origin throws "Permission denied to access property 'ownerDocument'" +// (a SecurityError variant). Treat any such throw as "not in DOM" so the +// surrounding observer callback can continue. +function safeOwnerDocument(n: Node): Document | null { + try { + return n.ownerDocument; + } catch { + return null; + } +} + export function shadowHostInDom(n: Node): boolean { - const doc = n.ownerDocument; + const doc = safeOwnerDocument(n); if (!doc) return false; const shadowHost = getRootShadowHost(n); return dom.contains(doc, shadowHost); } export function inDom(n: Node): boolean { - const doc = n.ownerDocument; + const doc = safeOwnerDocument(n); if (!doc) return false; return dom.contains(doc, n) || shadowHostInDom(n); } diff --git a/packages/rrweb/test/record/observer.test.ts b/packages/rrweb/test/record/observer.test.ts new file mode 100644 index 00000000..90d36123 --- /dev/null +++ b/packages/rrweb/test/record/observer.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { Mirror } from '@posthog/rrweb-snapshot'; + +import { initAdoptedStyleSheetObserver } from '../../src/record/observer'; + +describe('initAdoptedStyleSheetObserver', () => { + it('does not throw when host.ownerDocument access throws SecurityError', () => { + // Reproduces the Firefox "Permission denied to access property + // 'ownerDocument'" thrown when a same-origin shadow host's owning + // document later navigates cross-origin. The observer must bail out + // cleanly instead of re-throwing and aborting the recorder. + const mirror = new Mirror(); + const stylesheetManager = { adoptStyleSheets: vi.fn() } as any; + + const hostEl = document.createElement('div'); + document.body.appendChild(hostEl); + const shadowRoot = hostEl.attachShadow({ mode: 'open' }); + mirror.add(hostEl, { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + id: 1, + }); + Object.defineProperty(shadowRoot, 'ownerDocument', { + get() { + throw new DOMException( + "Permission denied to access property 'ownerDocument'", + 'SecurityError', + ); + }, + }); + + let cleanup: () => void = () => { + // + }; + expect(() => { + cleanup = initAdoptedStyleSheetObserver( + { mirror, stylesheetManager }, + shadowRoot, + ); + }).not.toThrow(); + expect(typeof cleanup).toBe('function'); + expect(() => cleanup()).not.toThrow(); + + document.body.removeChild(hostEl); + }); +}); diff --git a/packages/rrweb/test/util.test.ts b/packages/rrweb/test/util.test.ts index fda0030b..1e9cb0d1 100644 --- a/packages/rrweb/test/util.test.ts +++ b/packages/rrweb/test/util.test.ts @@ -142,5 +142,26 @@ describe('Utilities for other modules', () => { expect(shadowHostInDom(a.childNodes[0])).toBeTruthy(); expect(inDom(a.childNodes[0])).toBeTruthy(); }); + + it('should not throw when ownerDocument access throws SecurityError', () => { + // Reproduces the Firefox "Permission denied to access property + // 'ownerDocument'" thrown when a node's owning document has navigated + // cross-origin. inDom/shadowHostInDom must treat this as "not in DOM" + // rather than re-throwing and aborting the surrounding observer. + const node = Object.create(Node.prototype) as Node; + Object.defineProperty(node, 'ownerDocument', { + get() { + throw new DOMException( + "Permission denied to access property 'ownerDocument'", + 'SecurityError', + ); + }, + }); + + expect(() => inDom(node)).not.toThrow(); + expect(inDom(node)).toBeFalsy(); + expect(() => shadowHostInDom(node)).not.toThrow(); + expect(shadowHostInDom(node)).toBeFalsy(); + }); }); });