From d6cc9ad5733b3b624237c878f130b593802c7250 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 15 Nov 2024 12:02:43 +0100 Subject: [PATCH 1/3] catch calls to iframe.contentDocument in rrweb-snapshot --- packages/rrweb-snapshot/src/snapshot.ts | 5 +++-- packages/rrweb-snapshot/src/utils.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index f3d900364a..1d26432c11 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -29,6 +29,7 @@ import { shouldMaskInput, setTimeout, clearTimeout, + getIframeContentDocument, } from './utils'; let _id = 1; @@ -1046,7 +1047,7 @@ function serializeElementNode( if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { // Don't try to access `contentDocument` if iframe is blocked, otherwise it // will trigger browser warnings. - if (!needBlock && !(n as HTMLIFrameElement).contentDocument) { + if (!needBlock && !getIframeContentDocument(n as HTMLIFrameElement)) { // we can't record it directly as we can't see into it // preserve the src attribute so a decision can be taken at replay time attributes.rr_src = attributes.src; @@ -1384,7 +1385,7 @@ export function serializeNodeWithId( onceIframeLoaded( n as HTMLIFrameElement, () => { - const iframeDoc = (n as HTMLIFrameElement).contentDocument; + const iframeDoc = getIframeContentDocument(n as HTMLIFrameElement); if (iframeDoc && onIframeLoad) { const serializedIframeNode = serializeNodeWithId(iframeDoc, { doc: iframeDoc, diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index d7d824e4c2..e1b516801a 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -454,3 +454,17 @@ export function clearTimeout( ): ReturnType { return getImplementation('clearTimeout')(...rest); } + +/** + * Get the content document of an iframe. + * Catching errors is necessary because some older browsers block access to the content document of a sandboxed iframe. + */ +export function getIframeContentDocument(iframe?: HTMLIFrameElement) { + try { + if (iframe) { + return iframe.contentDocument; + } + } catch (e) { + // noop + } +} From 048b9e74cdee05ef664ac9c2b71e6c18ff77a235 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sat, 16 Nov 2024 14:55:18 +0100 Subject: [PATCH 2/3] catch all calls to iframe.contentDocument --- packages/rrdom/src/diff.ts | 6 +- packages/rrdom/src/index.ts | 4 +- packages/rrdom/src/util.ts | 27 +++++++ packages/rrweb/src/record/iframe-manager.ts | 13 ++-- packages/rrweb/src/record/mutation.ts | 6 +- .../rrweb/src/record/shadow-dom-manager.ts | 13 ++-- packages/rrweb/src/replay/index.ts | 72 +++++++++++-------- 7 files changed, 98 insertions(+), 43 deletions(-) create mode 100644 packages/rrdom/src/util.ts diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index c9e99e914f..a927832235 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -28,6 +28,7 @@ import type { RRDocument, Mirror, } from '.'; +import { getIFrameContentDocument } from './util'; const NAMESPACES: Record = { svg: 'http://www.w3.org/2000/svg', @@ -170,8 +171,9 @@ function diffBeforeUpdatingChildren( const newRRElement = newTree as IRRElement; switch (newRRElement.tagName) { case 'IFRAME': { - const oldContentDocument = (oldTree as HTMLIFrameElement) - .contentDocument; + const oldContentDocument = getIFrameContentDocument( + oldTree as HTMLIFrameElement, + ); // If the iframe is cross-origin, the contentDocument will be null. if (!oldContentDocument) break; // IFrame element doesn't have child nodes, so here we update its content document separately. diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index f1e9323c3a..728f3409c8 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -32,6 +32,7 @@ import { IRRText, IRRComment, } from './document'; +import { getIFrameContentDocument } from './util'; export class RRDocument extends BaseRRDocumentImpl(RRNode) { private UNSERIALIZED_STARTING_ID = -2; @@ -313,7 +314,7 @@ export function buildFromDom( } if (node.nodeName === 'IFRAME') { - const iframeDoc = (node as HTMLIFrameElement).contentDocument; + const iframeDoc = getIFrameContentDocument(node as HTMLIFrameElement); iframeDoc && walk(iframeDoc, rrNode); } else if ( node.nodeType === NodeType.DOCUMENT_NODE || @@ -485,3 +486,4 @@ export { RRNode }; export { diff, createOrGetNode, ReplayerHandler } from './diff'; export * from './document'; +export { getIFrameContentDocument, getIFrameContentWindow } from './util'; diff --git a/packages/rrdom/src/util.ts b/packages/rrdom/src/util.ts new file mode 100644 index 0000000000..c9d48e4eed --- /dev/null +++ b/packages/rrdom/src/util.ts @@ -0,0 +1,27 @@ +/** + * Get the content document of an iframe. + * Catching errors is necessary because some older browsers block access to the content document of a sandboxed iframe. + */ +export function getIFrameContentDocument(iframe?: HTMLIFrameElement) { + if (iframe) { + try { + return iframe.contentDocument; + } catch (e) { + // noop + } + } +} + +/** + * Get the content window of an iframe. + * Catching errors is necessary because some older browsers block access to the content document of a sandboxed iframe. + */ +export function getIFrameContentWindow(iframe?: HTMLIFrameElement) { + if (iframe) { + try { + return iframe.contentWindow; + } catch (e) { + // noop + } + } +} diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 04f3d7fe7d..e7138a830f 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -12,6 +12,7 @@ import type { mutationCallBack, } from '@sentry-internal/rrweb-types'; import type { StylesheetManager } from './stylesheet-manager'; +import { getIFrameContentDocument } from '@sentry-internal/rrdom'; export interface IframeManagerInterface { crossOriginIframeMirror: CrossOriginIframeMirror; @@ -109,14 +110,16 @@ export class IframeManager implements IframeManagerInterface { }); this.loadListener?.(iframeEl); + const iframeDoc = getIFrameContentDocument(iframeEl); + if ( - iframeEl.contentDocument && - iframeEl.contentDocument.adoptedStyleSheets && - iframeEl.contentDocument.adoptedStyleSheets.length > 0 + iframeDoc && + iframeDoc.adoptedStyleSheets && + iframeDoc.adoptedStyleSheets.length > 0 ) this.stylesheetManager.adoptStyleSheets( - iframeEl.contentDocument.adoptedStyleSheets, - this.mirror.getId(iframeEl.contentDocument), + iframeDoc.adoptedStyleSheets, + this.mirror.getId(iframeDoc), ); } private handleMessage(message: MessageEvent | CrossOriginIframeMessageEvent) { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 2b7b0bcdb0..bb6374b0a3 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -35,6 +35,7 @@ import { getShadowHost, closestElementOfNode, } from '../utils'; +import { getIFrameContentDocument } from '@sentry-internal/rrdom'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -628,7 +629,10 @@ export default class MutationBuffer { attributeName === 'src' && !this.keepIframeSrcFn(value as string) ) { - if (!(target as HTMLIFrameElement).contentDocument) { + const iframeDoc = getIFrameContentDocument( + target as HTMLIFrameElement, + ); + if (!iframeDoc) { // we can't record it directly as we can't see into it // preserve the src attribute so a decision can be taken at replay time attributeName = 'rr_src'; diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 3bce631067..512485b6ea 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -12,6 +12,10 @@ import { import { patch, inDom, setTimeout } from '../utils'; import type { Mirror } from '@sentry-internal/rrweb-snapshot'; import { isNativeShadowDom } from '@sentry-internal/rrweb-snapshot'; +import { + getIFrameContentDocument, + getIFrameContentWindow, +} from '@sentry-internal/rrdom'; type BypassOptions = Omit< MutationBufferParam, @@ -122,15 +126,16 @@ export class ShadowDomManager implements ShadowDomManagerInterface { * Monkey patch 'attachShadow' of an IFrameElement to observe newly added shadow doms. */ public observeAttachShadow(iframeElement: HTMLIFrameElement) { - if (!iframeElement.contentWindow || !iframeElement.contentDocument) return; - + const iframeDoc = getIFrameContentDocument(iframeElement); + const iframeWindow = getIFrameContentWindow(iframeElement); + if (!iframeDoc || !iframeWindow) return; this.patchAttachShadow( ( - iframeElement.contentWindow as Window & { + iframeWindow as Window & { Element: { prototype: Element }; } ).Element, - iframeElement.contentDocument, + iframeDoc, ); } diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 9269860a0a..5d02cac396 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -17,6 +17,8 @@ import { buildFromDom, diff, getDefaultSN, + getIFrameContentDocument, + getIFrameContentWindow, } from '@sentry-internal/rrdom'; import type { RRNode, @@ -266,10 +268,11 @@ export class Replayer { } }, }; - if (this.iframe.contentDocument) + const iframeDoc = getIFrameContentDocument(this.iframe); + if (iframeDoc) try { diff( - this.iframe.contentDocument, + iframeDoc, this.virtualDom, replayerHandler, this.virtualDom.mirror, @@ -553,7 +556,8 @@ export class Replayer { this.service.send({ type: 'PAUSE' }); this.service.send({ type: 'PLAY', payload: { timeOffset } }); } - this.iframe.contentDocument + const iframeDoc = getIFrameContentDocument(this.iframe); + iframeDoc ?.getElementsByTagName('html')[0] ?.classList.remove('rrweb-paused'); this.emitter.emit(ReplayerEvents.Start); @@ -567,9 +571,8 @@ export class Replayer { this.play(timeOffset); this.service.send({ type: 'PAUSE' }); } - this.iframe.contentDocument - ?.getElementsByTagName('html')[0] - ?.classList.add('rrweb-paused'); + const iframeDoc = getIFrameContentDocument(this.iframe); + iframeDoc?.getElementsByTagName('html')[0]?.classList.add('rrweb-paused'); this.emitter.emit(ReplayerEvents.Pause); } @@ -638,13 +641,11 @@ export class Replayer { this.iframe.setAttribute('sandbox', attributes.join(' ')); this.disableInteract(); this.wrapper.appendChild(this.iframe); - if (this.iframe.contentWindow && this.iframe.contentDocument) { - smoothscrollPolyfill( - this.iframe.contentWindow, - this.iframe.contentDocument, - ); - - polyfill(this.iframe.contentWindow as IWindow); + const iframeDoc = getIFrameContentDocument(this.iframe); + const iframeWindow = getIFrameContentWindow(this.iframe); + if (iframeWindow && iframeDoc) { + smoothscrollPolyfill(iframeWindow, iframeDoc); + polyfill(iframeWindow as IWindow); } } @@ -816,7 +817,8 @@ export class Replayer { event: fullSnapshotEvent & { timestamp: number }, isSync = false, ) { - if (!this.iframe.contentDocument) { + const iframeDoc = getIFrameContentDocument(this.iframe); + if (!iframeDoc) { return this.warn('Looks like your replayer has been destroyed.'); } if (Object.keys(this.legacy_missingNodeRetryMap).length) { @@ -850,12 +852,12 @@ export class Replayer { this.mirror.reset(); rebuild(event.data.node, { - doc: this.iframe.contentDocument, + doc: iframeDoc, afterAppend, cache: this.cache, mirror: this.mirror, }); - afterAppend(this.iframe.contentDocument, event.data.node.id); + afterAppend(iframeDoc, event.data.node.id); for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -863,11 +865,10 @@ export class Replayer { (m) => m !== mutationInQueue, ); } - const { documentElement, head } = this.iframe.contentDocument; + const { documentElement, head } = iframeDoc; this.insertStyleRules(documentElement, head); if (!this.service.state.matches('playing')) { - const iframeHtmlElement = - this.iframe.contentDocument.getElementsByTagName('html')[0]; + const iframeHtmlElement = iframeDoc.getElementsByTagName('html')[0]; iframeHtmlElement && iframeHtmlElement.classList.add('rrweb-paused'); } @@ -927,6 +928,9 @@ export class Replayer { : this.mirror; type TNode = typeof mirror extends Mirror ? Node : RRNode; type TMirror = typeof mirror extends Mirror ? Mirror : RRDOMMirror; + const iframeContentDoc = getIFrameContentDocument( + iframeEl as HTMLIFrameElement, + ); const collected: AppendedIframe[] = []; const afterAppend = (builtNode: Node, id: number) => { @@ -934,9 +938,10 @@ export class Replayer { const sn = (mirror as TMirror).getMeta(builtNode as unknown as TNode); if ( sn?.type === NodeType.Element && - sn?.tagName.toUpperCase() === 'HTML' + sn?.tagName.toUpperCase() === 'HTML' && + iframeContentDoc ) { - const { documentElement, head } = iframeEl.contentDocument!; + const { documentElement, head } = iframeContentDoc; this.insertStyleRules( documentElement as HTMLElement | RRElement, head as HTMLElement | RRElement, @@ -955,14 +960,14 @@ export class Replayer { }; buildNodeWithSN(mutation.node, { - doc: iframeEl.contentDocument! as Document, + doc: iframeContentDoc as Document, mirror: mirror as Mirror, hackCss: true, skipChild: false, afterAppend, cache: this.cache, }); - afterAppend(iframeEl.contentDocument! as Document, mutation.node.id); + afterAppend(iframeContentDoc as Document, mutation.node.id); for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -993,7 +998,8 @@ export class Replayer { * pause when loading style sheet, resume when loaded all timeout exceed */ private waitForStylesheetLoad() { - const head = this.iframe.contentDocument?.head; + const iframeDoc = getIFrameContentDocument(this.iframe); + const head = iframeDoc?.head; if (head) { const unloadSheets: Set = new Set(); let timer: ReturnType | -1; @@ -1433,7 +1439,7 @@ export class Replayer { : d.fontSource, d.descriptors, ); - this.iframe.contentDocument?.fonts.add(fontFace); + getIFrameContentDocument(this.iframe)?.fonts.add(fontFace); } catch (error) { this.warn(error); } @@ -1460,7 +1466,10 @@ export class Replayer { // Only apply virtual dom optimization if the fast-forward process has node mutation. Because the cost of creating a virtual dom tree and executing the diff algorithm is usually higher than directly applying other kind of events. if (this.config.useVirtualDom && !this.usingVirtualDom && isSync) { this.usingVirtualDom = true; - buildFromDom(this.iframe.contentDocument!, this.mirror, this.virtualDom); + const iframeDoc = getIFrameContentDocument(this.iframe); + if (iframeDoc) { + buildFromDom(iframeDoc, this.mirror, this.virtualDom); + } // If these legacy missing nodes haven't been resolved, they should be converted to virtual nodes. if (Object.keys(this.legacy_missingNodeRetryMap).length) { for (const key in this.legacy_missingNodeRetryMap) { @@ -1559,7 +1568,8 @@ export class Replayer { }; const appendNode = (mutation: addedNodeMutation) => { - if (!this.iframe.contentDocument) { + const iframeDoc = getIFrameContentDocument(this.iframe); + if (!iframeDoc) { return this.warn('Looks like your replayer has been destroyed.'); } let parent: Node | null | ShadowRoot | RRNode = mirror.getNode( @@ -1601,7 +1611,7 @@ export class Replayer { ? mirror.getNode(mutation.node.rootId) : this.usingVirtualDom ? this.virtualDom - : this.iframe.contentDocument; + : iframeDoc; if (isSerializedIframe(parent, mirror)) { this.attachDocumentToIframe( mutation, @@ -1893,7 +1903,8 @@ export class Replayer { return this.debugNodeNotFound(d, d.id); } const sn = this.mirror.getMeta(target); - if (target === this.iframe.contentDocument) { + const iframeDoc = getIFrameContentDocument(this.iframe); + if (target === iframeDoc) { this.iframe.contentWindow?.scrollTo({ top: d.y, left: d.x, @@ -2235,7 +2246,8 @@ export class Replayer { } private hoverElements(el: Element) { - const rootElement = this.lastHoveredRootNode || this.iframe.contentDocument; + const iframeDoc = getIFrameContentDocument(this.iframe); + const rootElement = this.lastHoveredRootNode || iframeDoc; // Sometimes this throws because `querySelectorAll` is not a function, // unsure of value of rootElement when this occurs From c92a839d389845ebff4e42e022b8c2adc047b2de Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 18 Nov 2024 13:51:08 +0100 Subject: [PATCH 3/3] simplify util functions --- packages/rrdom/src/util.ts | 20 ++++++++------------ packages/rrweb-snapshot/src/utils.ts | 4 +--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/rrdom/src/util.ts b/packages/rrdom/src/util.ts index c9d48e4eed..6287849e97 100644 --- a/packages/rrdom/src/util.ts +++ b/packages/rrdom/src/util.ts @@ -3,12 +3,10 @@ * Catching errors is necessary because some older browsers block access to the content document of a sandboxed iframe. */ export function getIFrameContentDocument(iframe?: HTMLIFrameElement) { - if (iframe) { - try { - return iframe.contentDocument; - } catch (e) { - // noop - } + try { + return (iframe as HTMLIFrameElement).contentDocument; + } catch (e) { + // noop } } @@ -17,11 +15,9 @@ export function getIFrameContentDocument(iframe?: HTMLIFrameElement) { * Catching errors is necessary because some older browsers block access to the content document of a sandboxed iframe. */ export function getIFrameContentWindow(iframe?: HTMLIFrameElement) { - if (iframe) { - try { - return iframe.contentWindow; - } catch (e) { - // noop - } + try { + return (iframe as HTMLIFrameElement).contentWindow; + } catch (e) { + // noop } } diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index e1b516801a..1d6ccb4d0c 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -461,9 +461,7 @@ export function clearTimeout( */ export function getIframeContentDocument(iframe?: HTMLIFrameElement) { try { - if (iframe) { - return iframe.contentDocument; - } + return (iframe as HTMLIFrameElement).contentDocument; } catch (e) { // noop }