From fed8a1841e2403901ff211401c985957d85ac0c0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:35:06 +0000 Subject: [PATCH] bun:test: don't leak JSC scope objects through a mock's this value For `fn()` calls of captured, imported, or global lexical bindings, JSC places the resolved scope object in the `this` register and leaves sanitization to the callee. jsMockFunctionCall used the raw value, so `mock.contexts` and `mockReturnThis()` handed JSLexicalEnvironment and JSModuleEnvironment objects to user code, and property operations on those break engine invariants (assertion in debug, abort in release). Convert the incoming `this` with toThis() in strict mode, the same conversion ProxyObject::performCall uses. Scope objects become undefined; other receivers are unchanged. --- src/jsc/bindings/JSMockFunction.cpp | 4 +++- test/js/bun/test/mock-fn.test.js | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 9b39827ad788..b7f8e623bf78 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -838,7 +838,9 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje } JSC::ArgList args = JSC::ArgList(callframe); - JSValue thisValue = callframe->thisValue(); + // For `fn()` calls JSC places the resolved scope (a JSScope) in the `this` slot and expects the + // callee to sanitize it. Do so before exposing it to JS, like ProxyObject::performCall does. + JSValue thisValue = callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict()); JSC::JSArray* argumentsArray = nullptr; { JSC::ObjectInitializationScope object(vm); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 7f6a244d9806..f84c295729c5 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -238,6 +238,20 @@ describe("mock()", () => { const obj = { fn }; expect(obj.fn()).toBe(obj); }); + test("bare call through a captured binding has an undefined this value", () => { + const fn = jest.fn().mockReturnThis(); + let returned; + { + const ref = fn; + // Capturing `ref` in a closure makes the engine resolve `ref()` through a + // scope object; that scope must not be observable as the call's `this`. + const capture = () => ref; + returned = ref(); + expect(capture()).toBe(fn); + } + expect(returned).toBeUndefined(); + expect(fn.mock.contexts).toEqual([undefined]); + }); if (isBun) { test("jest.fn(10) return value shorthand", () => { expect(jest.fn(10)()).toBe(10);