Skip to content

Replay: unguarded cross-origin read in ShadowDomManager.observeAttachShadow throws into the host page #23795

Description

@sagar7993

Is there an existing issue for this?

I searched the existing issues and did not find one covering this specific unguarded property access.

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/browser

SDK Version

10.73.0 (also reproduced on 10.72.0 — the relevant code is byte-for-byte identical between the two)

Framework Version

React 19, Vite. The SDK runs inside an embeddable widget that is loaded into third-party pages we do not control.

Link to Sentry event

Not linkable — the events are in a private project. Full sanitized stack traces are below.

Reproduction Example/SDK Setup

Sentry.init({
  dsn: '…',
  integrations: [
    Sentry.replayIntegration({ maskAllText: false, blockAllMedia: false }),
  ],
  replaysSessionSampleRate: 0,
  replaysOnErrorSampleRate: 1.0,
});

Steps to Reproduce

  1. Initialise the browser SDK with replayIntegration on a page that embeds one or more cross-origin iframes.
  2. Let a replay session start and let those iframes load.
  3. When rrweb's IframeManager fires onIframeLoad for such an iframe, ShadowDomManager.observeAttachShadow() runs and reads iframeWindow.Element.

I have not been able to isolate the exact origin state of the iframe at the moment it throws — I suspect a frame whose document is still readable when the load handler runs but whose Window is already cross-origin restricted. I would rather report the unguarded access than guess at the precise trigger; happy to gather more if useful.

Expected Result

Replay skips iframes it cannot access and continues recording. Instrumentation should not raise errors into the host page's window.onerror / onunhandledrejection.

Actual Result

Two unhandled errors, both originating inside @sentry/replay, captured via mechanism: auto.browser.global_handlers.*.

Variant 1 — TypeError

TypeError: Cannot read properties of undefined (reading 'prototype')
  @sentry/replay/build/npm/esm/index.js:649:7   (HTMLIFrameElement.<anonymous>)  listener();
  @sentry/replay/build/npm/esm/index.js:1286:13 (<anonymous>)                    onIframeLoad(
  @sentry/replay/build/npm/esm/index.js:2118:35 (onIframeLoad)                   this.shadowDomManager.observeAttachShadow(iframe);
  @sentry/replay/build/npm/esm/index.js:3905:10 (observeAttachShadow)            this.patchAttachShadow(
  @sentry/replay/build/npm/esm/index.js:3917:17 (patchAttachShadow)              element.prototype,

Variant 2 — SecurityError (DOMException.code: 18)

SecurityError: Failed to read a named property 'Element' from 'Window':
Blocked a frame with origin "https://<host-page-origin>" from accessing a cross-origin frame.
  @sentry/replay/build/npm/esm/index.js:1286:13 (HTMLIFrameElement.<anonymous>)  onIframeLoad(
  @sentry/replay/build/npm/esm/index.js:2118:35 (onIframeLoad)                   this.shadowDomManager.observeAttachShadow(iframe);

Analysis

The guard in ShadowDomManager.observeAttachShadow covers two accesses but not the one that actually fails:

observeAttachShadow(iframeElement) {
  const iframeDoc = getIFrameContentDocument(iframeElement);
  const iframeWindow = getIFrameContentWindow(iframeElement);
  if (!iframeDoc || !iframeWindow) return;
  this.patchAttachShadow(
    iframeWindow.Element,   // <-- unguarded cross-origin read
    iframeDoc
  );
}

Both helpers are defensive:

function getIFrameContentDocument(iframe) { try { return iframe.contentDocument; } catch {} }
function getIFrameContentWindow(iframe)   { try { return iframe.contentWindow;   } catch {} }

which is what makes the guard look sufficient. But iframe.contentWindow does not throw for a cross-origin frame — the browser returns a restricted Window proxy, so iframeWindow is truthy and the early return does not fire. The subsequent bare iframeWindow.Element read then either throws SecurityError (variant 2) or evaluates to undefined, so element.prototype inside patchAttachShadow throws TypeError (variant 1).

In other words: the two accesses that are wrapped are not the ones that fail, and the one that fails is not wrapped.

Suggested Fix

Guard the property read the same way the others are, and bail when the constructor is not reachable:

observeAttachShadow(iframeElement) {
  const iframeDoc = getIFrameContentDocument(iframeElement);
  const iframeWindow = getIFrameContentWindow(iframeElement);
  if (!iframeDoc || !iframeWindow) return;

  let iframeElementConstructor;
  try {
    iframeElementConstructor = iframeWindow.Element;
  } catch {
    return;   // cross-origin frame — nothing to observe
  }
  if (!iframeElementConstructor?.prototype) return;

  this.patchAttachShadow(iframeElementConstructor, iframeDoc);
}

Why this matters beyond the noise

For an SDK embedded in pages the SDK user does not control, an instrumentation error that escapes to window.onerror is attributed to the host page and is visible to whoever owns it. Replay is a passive recorder, so it should fail closed on frames it cannot reach rather than raise into the page.

Happy to open a PR with the above if that is welcome.

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions