Skip to content

Describe a value thrown out of a ShadowRealm without running any of its code - #4183

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/shadowrealm-evaluate-error-copy
Sep 25, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/shadowrealm-evaluate-error-copy

Conversation

@lahma

@lahma lahma commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #4176.

The defect

ShadowRealm.prototype.evaluate copies a failure into a TypeError of the calling realm, and it built that copy's message with ToString of the thrown value (JsValue.ToString() → TypeConverter.ToString / Error.prototype.toString). That calls toString, @@toPrimitive, a name or message getter, and every proxy trap along the way. CreateTypeErrorCopy says of itself that it "must not cause any ECMAScript code execution", and the host-provided message is covered by that too.

These were measured on main at 7d9cdcc8f:

thrown inside the shadow realm what reached the caller shadow-realm code run
throw { toString() { ran++; return 'custom'; } } TypeError: Cross-Realm Error: custom 1 call
throw { toString() { ran++; throw new RangeError('from toString'); } } the shadow realm's RangeError, not a TypeError 1 call
an Error with a message getter TypeError: Cross-Realm Error: Error: g 1 call
new Proxy(new Error('pe'), { get … }) TypeError: Cross-Realm Error: Error: pe 4 trap calls
new ShadowRealm().evaluate("throw new Error('boom')") in a shadow realm Cross-Realm Error: TypeError: Cross-Realm Error: Error: boom none

The second row also broke isolation: an object from the shadow realm crossed the callable boundary. The last row is the mark #4176 stopped repeating for WrappedFunction. It was still added once per level here, because this path formatted the error value (TypeError: Cross-Realm Error: …) rather than reusing a message.

test262 at the pinned SHA (7ab7fafa) doesn't cover any of this. built-ins/ShadowRealm/prototype/evaluate/errors-from-the-other-realm-is-wrapped-into-a-typeerror.js and its siblings only assert the constructor of what is thrown, and none of them throws a value with an observable toString, getter or trap.

The fix

CopiedErrorMessage builds the message without calling anything:

  • A primitive is its own string form. A symbol uses its descriptive string, because ToString refuses symbols.
  • An object with [[ErrorData]] (JsError or IErrorData, the same test as Error.isError) is rendered as the line Error.prototype.toString would produce. name and message come from ValueSlotReader.ErrorText, the console's call-free reader. It uses a value only when it is a data property holding a string, and it stops at an accessor or a proxy anywhere on the prototype walk.
  • Any other object, including a proxy of anything, is described by kind alone: an object that is not an Error was thrown.
  • An error that is itself a copy keeps its message as it is, so ThrowCrossRealmError marks a failure leaving nested realms once.

For an ordinary error or a primitive the message is the same as before (Cross-Realm Error: ReferenceError: aaa, Cross-Realm Error: 42), and tests pin that.

importValue: its rejection handler was %ThrowTypeError%. That runs nothing, but it reported a module that failed to evaluate with the strict-mode 'caller', 'callee', and 'arguments' properties may not be accessed… message. It is now the proposal's ImportValueError function (length 1, name "", in the caller's realm), which performs the same copy. The new anchor is registered in SpecAnchors.txt.

Tests

Jint.Tests/Runtime/ShadowRealmErrorCopyTests.cs, 26 cases:

  • the two repros: toString is not called, and a throwing toString still yields the caller's TypeError
  • @@toPrimitive and valueOf
  • message and name getters, including a throwing getter and a getter on a subclass prototype
  • a non-string message, and a proxy on the error's prototype chain
  • thrown proxies of an object, an error and a function, with every trap counted
  • nested evaluate two and three realms deep, marked once
  • importValue of a module that throws an error, and of one that throws an object with a toString
  • 8 rows pinning the unchanged message for ordinary errors and primitives

Fails before: on 7d9cdcc8f without the fix, 18 of the 26 fail. The 8 that pass are the rows that pin the unchanged message. For example: a throwing toString got not a TypeError of this realm|from toString|ran=1, and two realms deep got …Error: TypeError: Cross-Realm Error: Error: boom.

Passes after:

suite net10.0 net472
Jint.Tests 12,802 total / 12,797 passed / 0 failed / 5 skipped 8,850 total / 8,844 passed / 0 failed / 6 skipped
Jint.Tests.PublicInterface 3,817 total / 3,796 passed / 0 failed / 21 skipped 3,024 total / 3,003 passed / 0 failed / 21 skipped
test262 (full) 102,740 total / 102,651 passed / 0 failed / 89 skipped (control on main: 102,651 / 0 / 89) —

Base: 7d9cdcc8fa1179b8ea92db17f617f6154dc56b7e (upstream main). Immediately before opening this, upstream was fetched again and the branch rebased onto it. The rebase was a no-op, so the full runs above are on the pushed commit. The ShadowRealm, spec-citation and error-message fixtures were run once more after the rebase, on both frameworks, and all passed.

Not changed here

WrappedFunction's [[Call]] and WrappedFunctionCreate still copy JavaScriptException.Message. That message is built when the throw leaves a function body, through [[Get]] of message. So the getter has already run by the time the copy is made, and it runs outside ShadowRealm too. Measured on main with plain script:

function f() { throw errorWithMessageGetter; }
try { f(); } catch {}

This runs the getter twice (and throw new Proxy({}, { get }) fires the get trap twice). That's an engine-wide defect with its own fix. Once it's fixed, the two wrapped-function sites can derive their message from ex.Error through CopiedErrorMessage, and then the copy will read the same whichever path it crossed.

🤖 Generated with Claude Code

…ts code

ShadowRealm.prototype.evaluate copied a failure into the caller's TypeError
by formatting the thrown value with ToString, which calls toString,
@@toPrimitive, a name or message getter and every proxy trap on the way.
CreateTypeErrorCopy must not cause any ECMAScript code execution, and when
one of those calls threw, its exception - an object of the shadow realm -
reached the caller instead of the TypeError.

The copy's message is now read without calling anything: a primitive's own
string form, and for an object with [[ErrorData]] its name and message where
each is a data property holding a string (ValueSlotReader.ErrorText, the
console's call-free reader). Any other object, a proxy included, is described
by kind alone. An error that is itself a copy keeps its message, so a failure
leaving nested shadow realms is marked "Cross-Realm Error: " once, as a chain
of wrapped functions already is.

importValue's rejection handler was %ThrowTypeError%, which ran nothing but
reported a failed import as a 'caller'/'callee'/'arguments' access. It is now
the proposal's ImportValueError function, performing the same copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 8557142 into sebastienros:main Sep 25, 2026
9 checks passed
@lahma
lahma deleted the fix/shadowrealm-evaluate-error-copy branch September 25, 2026 18:57
lahma added a commit that referenced this pull request Oct 5, 2026
…lling the host or running script (#4220)

* Backport #4176 to 4.x: Throw a ShadowRealm cross-realm TypeError after its catch block, not inside it

WrappedFunction's [[Call]] and WrappedFunctionCreate copied a failure into
a TypeError by throwing from inside the catch handling it. A throw from a
handler is dispatched on top of every frame down to the throw it handles,
so a failure crossing a chain of wrapped functions nested one exception
dispatch per hop, which no native stack probe sees: the StackOverflowGuard's
catchable RangeError at the bottom of the chain became a process-ending
stack overflow on the way back up.

Both sites now record the failure and throw once the handler has returned,
and the "Cross-Realm Error: " mark is added once instead of once per hop.

Adapted for 4.x: StackOverflowGuard is opt-in on this branch, so the new
test rows run on the file's Guarded() engine; xUnit TheoryData instead of
main's TestCases; the gotcha goes in the root AGENTS.md (4.x has no
Jint/Constraints/AGENTS.md) and there is no SpecAnchors.txt to register.

Adapted from c1c180b

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Backport #4183 to 4.x: Describe a value thrown out of a ShadowRealm without running any of its code

ShadowRealm.prototype.evaluate copied a failure into the caller's TypeError
by formatting the thrown value with ToString, which calls toString,
@@toPrimitive, a name or message getter and every proxy trap on the way.
CreateTypeErrorCopy must not cause any ECMAScript code execution, and when
one of those calls threw, its exception - an object of the shadow realm -
reached the caller instead of the TypeError.

The copy's message is now read without calling anything: a primitive's own
string form, and for an error its name and message where each is a data
property holding a string. Any other object, a proxy included, is described
by kind alone. An error that is itself a copy keeps its message, so a failure
leaving nested shadow realms is marked "Cross-Realm Error: " once.

importValue's rejection handler was %ThrowTypeError%, which reported a failed
import as a 'caller'/'callee'/'arguments' access. It is now the proposal's
ImportValueError function, performing the same copy.

Adapted for 4.x: main reads the error through Jint.Diagnostics.ValueSlotReader
(ErrorText/ErrorLine), which this branch does not have; the same call-free
descriptor walk is written as private helpers in ShadowRealm.cs, without the
IErrorTextSlots branch (DOMException lives in Jint/WebApi, absent here). The
[[ErrorData]] test is `is JsError`, as Error.isError's is on this branch.
Tests use xUnit InlineData; there is no SpecAnchors.txt to register.

Adapted from 8557142

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant