Skip to content

FunctionToString integration's patched Function.prototype.toString throws SecurityError when its realm's browsing context was navigated cross-origin #21965

Description

@JT-Well

Is there an existing issue for this?

  • I have checked for existing issues
  • I have reviewed the documentation
  • I am using the latest SDK release or the bug is reproducible on master

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/nuxt (the affected code is in @sentry/core, so all browser SDKs are affected)

SDK Version

10.34.0 — the affected code is unchanged on current master (packages/core/src/integrations/functiontostring.ts)

Framework Version

Nuxt 4 (not framework-specific)

Description

The functionToStringIntegration (enabled by default) patches Function.prototype.toString. The patched function calls getClient()getCurrentScope()getMainCarrier()getSentryCarrier(GLOBAL_OBJ) on every .toString() invocation, which performs a property read of GLOBAL_OBJ.__SENTRY__.

GLOBAL_OBJ is globalThis, which in a Window realm is the WindowProxy of the realm's browsing context. If that browsing context is later navigated to a cross-origin document while code from the old realm can still be invoked (e.g. a still-alive parent window holding references into a same-origin child iframe whose src changed to a cross-origin URL), the __SENTRY__ read on the WindowProxy throws:

SecurityError: Failed to read a named property '__SENTRY__' from 'Window':
Blocked a frame with origin "https://app.example.com" from accessing a cross-origin frame.

(current Chrome wording: Blocked a restricted frame with origin "…" from accessing another frame.)

Because the patch is installed on Function.prototype, the throw is triggered by any third-party script calling .toString(). In our production case the chain was: Microsoft Clarity's MutationObserver serialization → Tawk.to's core-js Function.prototype.toString wrapper → Facebook Pixel's (fbevents.js) core-js inspectSource → Sentry's patched toStringSecurityError, surfacing as onunhandledrejection noise (24 events within ~1 second from a single page transition on a page embedding a third-party game iframe with sandbox="... allow-top-navigation").

Production stack (origins redacted, resolved via source maps):

/0.8.66/clarity.js (async/await machinery, MutationObserver serialization)
/_s/v4/app/.../twk-chunk-vendors.js  (ShadowRoot.toString — core-js toString wrapper)
/en_US/fbevents.js  (core-js inspectSource)
@sentry/core/build/esm/integrations/functiontostring.js:24  (Function.prototype.toString)
@sentry/core/build/esm/currentScopes.js:101  (getClient)
@sentry/core/build/esm/currentScopes.js:10   (getCurrentScope)
@sentry/core/build/esm/carrier.js:18         (getMainCarrier → getSentryCarrier(GLOBAL_OBJ) → __SENTRY__ read throws)

Note: the native Function.prototype.toString never throws for these calls — the throw is introduced solely by the integration's internal carrier access, so the SDK converts harmless third-party introspection into uncatchable (for the app) SecurityError noise, which Sentry itself then reports.

Steps to Reproduce

Self-contained repro (Chrome). Serve the two files below from http://127.0.0.1:8801 and also expose the same directory on http://127.0.0.1:8802 (different port = cross-origin but same-site, any static server works):

parent.html:

<!doctype html>
<iframe id="f" src="/child.html"></iframe>
<script>
  const f = document.getElementById('f')
  let done = false
  f.onload = () => {
    if (done) return
    done = true
    const childFn = f.contentWindow.exposedFn // keep a reference into the child realm
    setTimeout(() => { f.src = 'http://127.0.0.1:8802/parent.html' }, 300) // navigate iframe cross-origin
    setTimeout(() => {
      try {
        childFn.toString() // resolves to the child realm's patched Function.prototype.toString
        console.log('ok')
      } catch (e) {
        console.log('THREW', e.name, e.message)
      }
    }, 1500)
  }
</script>

child.html (stands in for any same-origin frame that initialized a Sentry browser SDK — inlined replica of functiontostring.ts + carrier.ts so the repro needs no bundler; using the real SDK behaves identically):

<!doctype html>
<script>
  const SDK_VERSION = '10.34.0'
  const GLOBAL_OBJ = globalThis
  function getSentryCarrier(carrier) {
    const __SENTRY__ = (carrier.__SENTRY__ = carrier.__SENTRY__ || {})
    __SENTRY__.version = __SENTRY__.version || SDK_VERSION
    return (__SENTRY__[SDK_VERSION] = __SENTRY__[SDK_VERSION] || {})
  }
  function getMainCarrier() { getSentryCarrier(GLOBAL_OBJ); return GLOBAL_OBJ }
  function getClient() { getMainCarrier(); return undefined }
  const SETUP_CLIENTS = new WeakMap()
  const originalFunctionToString = Function.prototype.toString
  Function.prototype.toString = function (...args) {
    const originalFunction = this && this.__sentry_original__
    const context = SETUP_CLIENTS.has(getClient()) && originalFunction !== undefined ? originalFunction : this
    return originalFunctionToString.apply(context, args)
  }
  window.exposedFn = function exposedFn() {}
</script>

Observed output:

THREW SecurityError Failed to read a named property '__SENTRY__' from 'Window': Blocked a restricted frame with origin "http://127.0.0.1:8801" from accessing another frame.

The same read also throws in any other "realm outlives its document's origin" situation (Android Chrome same-process cross-origin navigations of the top frame show the same signature).

Expected Result

The patched Function.prototype.toString should never throw where the native implementation would not. Suggested fix — wrap the patch body, mirroring the defensive style already used around the patch installation:

Function.prototype.toString = function (this: WrappedFunction, ...args: any[]): string {
  try {
    const originalFunction = getOriginalFunction(this);
    const context =
      SETUP_CLIENTS.has(getClient() as Client) && originalFunction !== undefined ? originalFunction : this;
    return originalFunctionToString.apply(context, args);
  } catch {
    // e.g. SecurityError reading __SENTRY__ off a WindowProxy whose browsing context
    // was navigated cross-origin — fall back to native behavior
    return originalFunctionToString.apply(this, args);
  }
};

We've verified (against @sentry/core 10.34.0) that this keeps the unwrap behavior intact for live realms and degrades to exact native semantics otherwise. Happy to open a PR if this direction is acceptable.

Actual Result

SecurityError: Failed to read a named property '__SENTRY__' from 'Window': Blocked a frame with origin "…" from accessing a cross-origin frame. thrown out of Function.prototype.toString, reported as unhandled-rejection error events.

Metadata

Metadata

Assignees

No one assigned

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions