From 280b0057197a51d547df9933a581a7bf91d464fe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:26:47 +0000 Subject: [PATCH] node:vm: keep compileFunction contextExtensions out of the realm's global scope extension vm.compileFunction installed the JSWithScope chain built from contextExtensions (or, without extensions, the global object itself) as the parsing context's global scope extension and never cleared it. JSC consults that hook whenever identifier resolution reaches the global object, so after one such call the last extension object's properties resolved from any later code in the realm: module code, runInThisContext, new Function, indirect eval, and functions compiled earlier. The extensions already sit in the compiled function's own scope chain, which is what the function resolves through, so the hook is not needed for the function itself. Drop it. --- src/jsc/bindings/NodeVM.cpp | 2 -- test/js/node/vm/vm.test.ts | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 54a3d25d2947..859a542386f2 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1619,8 +1619,6 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject } } - options.parsingContext->setGlobalScopeExtension(functionScope); - // Create the function using constructAnonymousFunction with the appropriate scope chain JSFunction* function = constructAnonymousFunction(globalObject, ArgList(constructFunctionArgs), sourceOrigin, WTF::move(options), JSC::SourceTaintedOrigin::Untainted, functionScope); RETURN_IF_EXCEPTION(scope, {}); diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index d3d04239d179..8da148acc946 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -269,6 +269,63 @@ describe("vm", () => { expect(e).toBeTruthy(); } }); + + describe("contextExtensions", () => { + // Node (V8's ScriptCompiler::CompileFunction) wraps the extensions around + // the compiled function only. Nothing else running in the realm may see them. + const probe = "[typeof fromFirstExtension, typeof fromSecondExtension]"; + const extensions = () => [{ fromFirstExtension: "first" }, { fromSecondExtension: "second" }]; + + test("are visible to the compiled function, later extensions shadow earlier ones", () => { + const fn = compileFunction( + "return [fromFirstExtension, fromSecondExtension, shadowed, () => shadowed, eval('shadowed')]", + [], + { + contextExtensions: [ + { fromFirstExtension: "first", shadowed: "from first" }, + { fromSecondExtension: "second", shadowed: "from second" }, + ], + }, + ); + const [first, second, shadowed, closure, evaluated] = fn(); + expect([first, second, shadowed, closure(), evaluated]).toEqual([ + "first", + "second", + "from second", + "from second", + "from second", + ]); + }); + + test("are not visible to code that runs in the realm afterwards", () => { + const compiledEarlier = compileFunction(`return ${probe}`); + const fn = compileFunction(`return ${probe}`, [], { contextExtensions: extensions() }); + expect(fn()).toEqual(["string", "string"]); + + const notVisible = ["undefined", "undefined"]; + // @ts-expect-error these identifiers are deliberately undeclared + expect([typeof fromFirstExtension, typeof fromSecondExtension]).toEqual(notVisible); + expect(runInThisContext(probe)).toEqual(notVisible); + expect(new Function(`return ${probe}`)()).toEqual(notVisible); + expect((0, eval)(probe)).toEqual(notVisible); + expect(compiledEarlier()).toEqual(notVisible); + expect(compileFunction(`return ${probe}`)()).toEqual(notVisible); + }); + + test("are not visible to code that runs in parsingContext afterwards", () => { + const parsingContext = createContext({}); + const compiledEarlier = compileFunction(`return ${probe}`, [], { parsingContext }); + const fn = compileFunction(`return ${probe}`, [], { parsingContext, contextExtensions: extensions() }); + expect(fn()).toEqual(["string", "string"]); + + const notVisible = ["undefined", "undefined"]; + expect(runInContext(probe, parsingContext)).toEqual(notVisible); + expect(runInContext(`new Function("return ${probe}")()`, parsingContext)).toEqual(notVisible); + expect(compiledEarlier()).toEqual(notVisible); + expect(compileFunction(`return ${probe}`, [], { parsingContext })()).toEqual(notVisible); + expect(runInThisContext(probe)).toEqual(notVisible); + }); + }); }); });