Skip to content

[JSC] ShadowRealm: wrap a generic target's return value for the caller's realm - #590

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b1492092/shadow-realm-wrapper-caller-realm
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b1492092/shadow-realm-wrapper-caller-realm

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A wrapped function that returns a callable must wrap that callable for the caller's realm. When the wrapped function's target is not a plain JSFunction, JSC wrapped it for the target's realm instead. The caller then holds a function object whose [[Prototype]] is the other realm's Function.prototype, so .constructor on it is that realm's genuine Function, and F("return globalThis")() is that realm's global object.
  • The cause is remoteFunctionCallGeneric (Source/JavaScriptCore/runtime/JSRemoteFunction.cpp:158), which ended with wrapReturnValue(globalObject, targetGlobalObject, result). remoteFunctionCallForJSFunction (line 122) and the JIT thunk's operationGetWrappedValueForCaller (jit/JITOperations.cpp:189) both wrap for the caller's realm, which is what OrdinaryWrappedFunctionCall specifies.
  • Targets that take the generic path: a callable Proxy, a Proxy with an apply trap, and any InternalFunction, for example the realm's own Function. The leak runs both ways, into a shadow realm and out of one.
const r = new ShadowRealm();
const made = r.evaluate(`Function`)("return 1");
print(Object.getPrototypeOf(made) === Function.prototype);  // false, expected true
const realmGlobal = Object.getPrototypeOf(made).constructor("return globalThis")();
realmGlobal.injected = { list: [] };
print(r.evaluate(`JSON.stringify(globalThis.injected)`));   // {"list":[]}, expected undefined

Fix

  • wrapReturnValue now takes one global object, the caller's realm, and both call paths pass it. The pair cannot be passed the wrong way around any more.
  • globalObject is the caller's realm inside these host functions. The native thunk loads the global object from the callee's scope, and the callee is the wrapped function itself. operationGetWrappedValueForCaller reaches the same realm through callee->realm().
  • Arguments keep the pair. They cross in the other direction, so they stay wrapped for the target's realm.
  • test262 covers the realm of a returned wrapper only for a plain function target (wrapped-function-proto-from-caller-realm.js), so the generic path diverged unnoticed. JSTests/stress/shadow-realm-wrapped-function-return-value-realm.js covers one target of each kind, plus both directions.

Verification

Background

  • A JSRemoteFunction is the wrapper that a value gets when it crosses a realm boundary. Its structure and [[Prototype]] come from the global object that JSRemoteFunction::tryCreate receives, so that argument decides which realm owns the wrapper.
  • VM::getRemoteFunction(isJSFunction) picks the call path when the wrapper is created. A plain JSFunction target gets remoteFunctionCallForJSFunction and the remoteFunctionCallGenerator thunk. Every other callable gets remoteFunctionCallGeneric.
  • A bound function is a JSFunction, so f.bind() targets take the fast path and were never affected.

…r's realm

remoteFunctionCallGeneric wrapped the return value of a wrapped function
for the target's realm. remoteFunctionCallForJSFunction and the JIT
thunk's operationGetWrappedValueForCaller both wrap it for the caller's
realm, which is what OrdinaryWrappedFunctionCall specifies.

So a wrapped function whose target is not a plain JSFunction (a callable
Proxy, or a built-in such as the realm's own Function) returned a wrapper
whose [[Prototype]] is the target realm's Function.prototype. The caller
reads .constructor off that prototype, gets the target realm's genuine
Function constructor, and through it that realm's global object. The
leak runs both ways: into a shadow realm and out of one.

wrapReturnValue now takes one global object, the caller's realm, so the
two cannot be swapped.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

for (const source of sources) {
const wrapped = new ShadowRealm().evaluate(source);
for (let i = 0; i < 200; i++)
shouldBe(wrapperComesFromThisRealm(wrapped), true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): The inner loop hardcodes 200 iterations instead of using testLoopCount, which JSTests/README.md (imported by JSTests/CLAUDE.md) says new tests are required to use so the harness can raise the count for tier-up-sensitive configurations and drop it for the rest. Fix: replace 200 with testLoopCount so eager-JIT configurations exercise the remoteFunctionCallGenerator thunk path and no-JIT configurations exit quickly.

Extended reasoning...

JSTests/CLAUDE.md @ README.md-imports JSTests/README.md, whose rule 2 states new tests must use testLoopCount (or wasmTestLoopCount) to control iteration counts because the jsc CLI sets it per configuration. The new test's loop at line 27 uses a literal 200. In ftl-eager/no-cjit configurations 200 may not reach the JIT thunk that also wraps return values, and in interpreter-only configurations it wastes time — either way it diverges from the mandated convention that every other stress test in the tree follows (e.g. stress/impure-get-own-property-slot-inline-cache.js:13).

Verification: nit: JSTests/CLAUDE.md line 1 @ README.md-imports JSTests/README.md, whose rule 2 (line 20) states new tests are required to "Use testLoopCount or wasmTestLoopCount to control how many iterations a test runs. The jsc CLI sets these based on the configuration of the test, so tests iterate enough to tier up where that matters and exit early where it doesn't." The global is real —…

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 95160ae0-b812-4478-8196-179327f1431c

📥 Commits

Reviewing files that changed from the base of the PR and between 10350dd and c7520f6.

📒 Files selected for processing (2)
  • JSTests/stress/shadow-realm-wrapped-function-return-value-realm.js
  • Source/JavaScriptCore/runtime/JSRemoteFunction.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

ShadowRealm return-value wrapping

Layer / File(s) Summary
Caller-realm return wrapping
Source/JavaScriptCore/runtime/JSRemoteFunction.cpp
wrapReturnValue uses the caller global object for both wrapping realms. Both remote-call paths use the updated helper.
ShadowRealm callable coverage
JSTests/stress/shadow-realm-wrapped-function-return-value-realm.js
The stress test covers callable targets, returned function prototypes, constructor behavior, globalThis, and reverse-direction calls.

Priority: ➖ Normal — Schedule the ShadowRealm runtime fix because cross-realm callable returns can expose the wrong realm’s prototypes and globals across multiple callable paths.

Merge Risk: ⚪ Minimal · up to c7520

Returned callables now retain the invoking realm’s behavior across ShadowRealm boundaries, with coverage for relevant callable forms and directions. No current merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the ShadowRealm fix for wrapping generic target return values in the caller's realm.
Description check ✅ Passed The description provides a detailed problem statement, root cause, fix, affected targets, test coverage, and verification results. It does not include the Bugzilla link, reviewer marker, or template-s…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
c7520f66 autobuild-preview-pr-590-c7520f66 2026-09-08 15:44:02 UTC

robobun added a commit to oven-sh/bun that referenced this pull request Sep 8, 2026
… target's return value for the caller's realm

Pins WEBKIT_VERSION at the preview build of oven-sh/WebKit#590. A
callable returned by a wrapped function whose target is not a plain
JSFunction (a callable Proxy, or a built-in such as the realm's own
Function) was wrapped for the target's realm instead of the caller's.
The caller could read the other realm's Function constructor off the
wrapper's prototype and reach that realm's global object, in both
directions.

The range from 2e2aa2290fac also contains oven-sh/WebKit#561, #566 and
#568, already merged on oven-sh/WebKit main.
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