Skip to content

node:vm: stop leaking compileFunction contextExtensions into later global lookups - #38302

Open
robobun wants to merge 1 commit into
mainfrom
farm/8e461f34/vm-compile-function-scope-extension-leak
Open

robobun wants to merge 1 commit into
mainfrom
farm/8e461f34/vm-compile-function-scope-extension-leak

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • After one vm.compileFunction(code, params, { contextExtensions: [ext] }) call, the properties of the last extension object resolve as globals from every later piece of code in that realm: ordinary module code, vm.runInThisContext, new Function, indirect eval, and functions that were compiled earlier. With parsingContext the same happens inside that context (vm.runInContext("typeof x", ctx) sees a previous compile's extensions). Node scopes the extensions to the compiled function only.
    vm.compileFunction("return leaked", [], { contextExtensions: [{ leaked: 1 }] });
    vm.runInThisContext("typeof leaked"); // bun: "number", node: "undefined"
  • Cause: vmModuleCompileFunction in src/jsc/bindings/NodeVM.cpp called options.parsingContext->setGlobalScopeExtension(functionScope) before compiling and never cleared it. JSScope::resolve (vendor/WebKit/Source/JavaScriptCore/runtime/JSScope.cpp) consults the realm's global scope extension whenever a lookup reaches the global object, so the JSWithScope chain stayed visible realm-wide. Without extensions the global object itself was installed, which is inert; a later extension-less compile therefore also happened to overwrite an earlier leak, so the symptom depends on call order.

Fix

  • Delete the setGlobalScopeExtension call. Nothing else in Bun installs a global scope extension, so the realm stays in the same state it is in before any compileFunction call.
  • Correct because the extensions already reach the compiled function through its own scope chain: functionScope (the JSWithScope chain on top of the context's global scope) is both what ProgramCodeBlock::create links against and the scope the JSFunction is created with. When JSC links a lookup against a chain containing a JSWithScope it marks the lookup Dynamic (JSScope::abstractAccess), and the runtime walk then finds the extension object before it ever reaches the global object, so the hook contributed nothing to the function itself. The hook also only ever exposed the head of the chain (JSScope::objectAtScope), which is why only the last extension leaked.
  • cachedData is unaffected: the globalScopeExtension() reads that remain in NodeVM.cpp, NodeVMScript.cpp and NodeVMSourceTextModule.cpp only choose TaintedByWithScopeLexicallyScopedFeature for a SourceCodeKey, and SourceCodeFlags keeps just the strict-mode bit of those features (parser/SourceCodeKey.h), so cached data produced before this change still decodes.
  • Verified with test/js/node/vm/vm.test.ts (compileFunction() > contextExtensions): the two "not visible" tests fail on the current release ("string" where "undefined" is expected) and pass with this change; the third test pins the behavior the function itself relies on (visibility, later extensions shadowing earlier ones, closures and direct eval inside the function).
    • Each probed path (module typeof, runInThisContext, new Function, indirect eval, an earlier compiled function, and the parsingContext equivalents) was also checked individually: all leak before, none after, and the output matches node v26.3.0.
    • test/js/node/test/parallel/test-vm-basic.js (covers contextExtensions, parsingContext and cachedData for compileFunction) passes.
    • test/regression/issue/isArray-proxy-crash.test.ts passes.
  • Touches the line node:vm: compile compileFunction sources in the parsingContext realm #38297 also edits, and sits next to the line node:vm: resolve global let/const/class bindings from compileFunction bodies #38305 edits (a function compiled without extensions cannot see global let/const bindings; not changed here). Whichever of these lands later needs a trivial rebase; the three changes are independent.

Background

  • Scope chain: every function carries the chain of scopes it closes over; identifier lookups walk it and end at the realm's global object. vm.compileFunction builds one JSWithScope per contextExtensions entry (same as a with (ext) {} block) and creates the function with that chain, mirroring how V8's ScriptCompiler::CompileFunction implements the option.
  • Global scope extension: a per-realm hook on JSGlobalObject (setGlobalScopeExtension / clearGlobalScopeExtension). When any lookup in the realm reaches the global object and misses, JSC also checks the object installed there. JSC's own users install it around a single evaluation and clear it afterwards, or install it once for the lifetime of an embedding context; it is realm state, not per-function state.

[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 62 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (280b00571)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [22.57ms]
(pass) vm > runInContext() > can return a value [16.10ms]
(pass) vm > runInContext() > can return a complex value [17.12ms]
(pass) vm > runInContext() > can return the last value [15.73ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [17.60ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.50ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.22ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [16.60ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [16.96ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [15.58ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [15.63ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [15.90ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release without fix: 2 failed, 62 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.53ms]
(pass) vm > runInContext() > can return a value [0.36ms]
(pass) vm > runInContext() > can return a complex value [0.31ms]
(pass) vm > runInContext() > can return the last value [0.25ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.29ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [0.26ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [0.26ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [0.28ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [0.31ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.24ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.23ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.24ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.26ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.24ms]
(pass) vm > runInContext() > new Big
... (truncated)
passes on PR (with fix)
ASAN with fix: 62 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (280b00571)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [21.64ms]
(pass) vm > runInContext() > can return a value [15.09ms]
(pass) vm > runInContext() > can return a complex value [15.87ms]
(pass) vm > runInContext() > can return the last value [14.68ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [17.10ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.06ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [15.47ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [15.70ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [15.44ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [15.64ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [14.91ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [15.30ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release with fix: 62 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     280b005719
  features     baseline

22 deps, 123 codegen, 1176 objects in 870ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [20.00ms]
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [2.00ms]
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] gen bindgenv2
[6/1237] fetch zlib
[zlib] up to date
[7/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[8/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1237] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/P
... (truncated)
diff hotspot
src/jsc/bindings/NodeVM.cpp |  2 --
 test/js/node/vm/vm.test.ts  | 57 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 57 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                         reads  edits  tests
src/jsc/bindings/NodeVM.cpp      5      3      0
test/js/node/vm/vm.test.ts       1      1      0

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f4b6f49-6a2e-4911-b303-57f87001f481

📥 Commits

Reviewing files that changed from the base of the PR and between 385aee6 and 280b005.

📒 Files selected for processing (2)
  • src/jsc/bindings/NodeVM.cpp
  • test/js/node/vm/vm.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (280b005). Reviews so far have no open threads.

Reproduced on bun 1.4.0 (node v26.3.0 prints undefined for every line below):

const vm = require("node:vm");
vm.compileFunction("return leaked", [], { contextExtensions: [{ leaked: 1 }] });
vm.runInThisContext("typeof leaked");          // "number"
new Function("return typeof leaked")();        // "number"
const ctx = vm.createContext({});
vm.compileFunction("return x", [], { parsingContext: ctx, contextExtensions: [{ x: 1 }] });
vm.runInContext("typeof x", ctx);              // "number"

With this branch all of the above print undefined, matching node. The new tests in test/js/node/vm/vm.test.ts (compileFunction() > contextExtensions) fail on the release build and pass with the change; test/js/node/test/parallel/test-vm-basic.js still passes.

CI: on the current sha, 177 of 179 jobs are green; the remaining two are the darwin 14 aarch64 test jobs, which have not been able to start (each attempt expires waiting for an agent and is re-queued). The only test failures in the build are retried flakes in unrelated files (napi, terminal, fake-timers, sqlite, resolve). The previous sha (284a961, identical source change apart from a comment) did run on darwin 14 aarch64 and vm.test.ts passed there; the two failures on that lane were vite/astro startup aborts unrelated to node:vm and have been reported separately.

Related PRs touching the same lines: #38297 (parsingContext realm) and #38305 (global let/const visibility); all three are independent and whichever lands later needs a trivial rebase.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — a one-line deletion of a call that leaked realm-wide state, with tests pinning both the fix and the behavior it must not regress.

What was reviewed:

  • Confirmed setGlobalScopeExtension is not called anywhere else in src/; the remaining globalScopeExtension() reads only feed SourceCodeKey, which the description addresses.
  • Verified functionScope still reaches ProgramCodeBlock::create and JSFunction::create inside constructAnonymousFunction, so the compiled function keeps its JSWithScope chain — test 1 pins this.
  • Checked the lexicallyScopedFeatures read at NodeVM.cpp:136 uses the caller's globalObject (not parsingContext), so its value only changes in the no-parsingContext case, and only for the cachedData decode key.
Extended reasoning...

Overview

This PR removes a single call — options.parsingContext->setGlobalScopeExtension(functionScope) — from vmModuleCompileFunction in src/jsc/bindings/NodeVM.cpp, replaces the adjacent comment with one explaining why the extension chain must live only on the function's scope, and adds three targeted tests to test/js/node/vm/vm.test.ts under a new compileFunction() > contextExtensions describe block.

The bug: setGlobalScopeExtension installs a realm-wide fallback consulted by JSScope::resolve for every unresolved global lookup, and it was never cleared. So after one compileFunction call with contextExtensions, those extensions bled into every subsequent piece of code in that realm. The fix relies on the fact that functionScope (the JSWithScope chain) is already passed as the scope argument to constructAnonymousFunction, which threads it into both ProgramCodeBlock::create and JSFunction::create — so the compiled function's own scope chain already carries the extensions, and the realm-wide hook contributed nothing to the function itself.

Security risks

None introduced. This actually closes a scoping leak where extension objects unexpectedly became visible to unrelated code in the realm. No new user-controlled input paths, no allocation/lifetime changes.

Level of scrutiny

Moderate. The change is one deleted line, but it sits in JSC scope-resolution machinery and Node-compat code. I traced:

  • No other setGlobalScopeExtension/clearGlobalScopeExtension callers exist in src/, so the realm's extension slot is now always null (its default).
  • The four remaining globalScopeExtension() reads (NodeVM.cpp ×3, NodeVMScript.cpp, NodeVMSourceTextModule.cpp) only pick TaintedByWithScopeLexicallyScopedFeature vs NoLexicallyScopedFeatures for a SourceCodeKey; the PR description explains why this doesn't affect cachedData compatibility.
  • The read at NodeVM.cpp:136 inside constructAnonymousFunction uses the caller's globalObject, so with a parsingContext it was already reading a different global than the one the removed line wrote — the behavior only changes in the no-parsingContext-with-extensions case, and only for the cachedData decode key.

Other factors

The tests are well-constructed: test 1 ("are visible to the compiled function") pins that the function itself still sees extensions (including via closure and direct eval), so the deletion cannot silently break the feature; tests 2 and 3 pin the leak fix across every path the description enumerates (module code, runInThisContext, new Function, indirect eval, previously-compiled functions, and the parsingContext equivalents). The PR states both test-vm-basic.js and isArray-proxy-crash.test.ts pass, and notes the trivial rebase interaction with #38297 and the separate global-lexical-environment issue that is intentionally out of scope.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. One refinement to the third point: the lexicallyScopedFeatures value read at NodeVM.cpp:136 does not change the cachedData decode key either. SourceCodeFlags (vendor/WebKit/Source/JavaScriptCore/parser/SourceCodeKey.h) masks those features down to the strict-mode bit before they go into the key, so whether or not the extension was installed, the key is the same. The only observable effect of the removed call was the lookup leak the tests cover.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 PM PT - Aug 13th, 2026

@robobun, your commit 284a961 is building: #95198

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

❌ @robobun, your commit 280b005 has some failures in Build #95895 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38302

That installs a local version of the PR into your bun-38302 executable, so you can run:

bun-38302 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #38305 (compileFunction creating the function on the global lexical environment so it can see script-level let/const) now contains this same one-line deletion of setGlobalScopeExtension. With the function created on the lexical environment, leaving the call in place would have installed the lexical environment realm-wide, which functions created directly on the global object (builtins, internal modules) can observe through their unresolved identifiers, so the deletion is needed there as well. The tests from this PR pass on that branch. Whichever of the two lands second rebases onto an identical deletion; the tests here remain the ones that cover the leak on its own.

Comment thread src/jsc/bindings/NodeVM.cpp Outdated
…obal 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.
@robobun
robobun force-pushed the farm/8e461f34/vm-compile-function-scope-extension-leak branch from a11a641 to 280b005 Compare August 14, 2026 10:54
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 280b005 (force-push, replaces 284a961 plus an empty retrigger commit): same fix and tests, with the three-line comment flagged by comment-cop dropped, so the source diff is now only the removal of the setGlobalScopeExtension call. Re-verified locally with bun bd test test/js/node/vm/vm.test.ts (217 pass) and bun bd test/js/node/test/parallel/test-vm-basic.js. The CodeRabbit comments on this PR so far are rate-limit notices only.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — deletes the realm-wide setGlobalScopeExtension call that was never cleared; the JSWithScope chain is already threaded through constructAnonymousFunction as the function's own scope.

What was reviewed:

  • Confirmed functionScope still reaches both ProgramCodeBlock::create and JSFunction::create, so the compiled function keeps seeing its extensions via its scope chain (test 1 pins this).
  • Grepped src/ — nothing else sets a global scope extension, so the remaining globalScopeExtension() reads now consistently yield NoLexicallyScopedFeatures; per the PR's SourceCodeKey.h analysis this does not change the cachedData key.
  • Tests cover both realms (main + parsingContext), multiple lookup paths (runInThisContext, new Function, indirect eval, earlier/later compileFunction), and the shadowing order — the negative assertions would fail on the unfixed build with "string" for the last extension.
Extended reasoning...

Overview

The PR deletes one line from vmModuleCompileFunction in src/jsc/bindings/NodeVM.cpp — the options.parsingContext->setGlobalScopeExtension(functionScope) call — and adds three tests under compileFunction() > contextExtensions in test/js/node/vm/vm.test.ts. The removed call was installing the per-call JSWithScope chain as realm-wide state and never clearing it, so properties of the last contextExtensions entry resolved as globals in every later evaluation in that realm. Node scopes these extensions to the compiled function only.

Security risks

None introduced. node:vm is sandbox-adjacent, but this change strictly removes a leak of per-call scope into realm-wide lookups — it tightens isolation rather than loosening it. The compiled function still sees its extensions because functionScope is passed as the scope argument to constructAnonymousFunction, which uses it for both ProgramCodeBlock::create(..., scope) and JSFunction::create(..., scope, ...). No new user-controlled data reaches native code.

Level of scrutiny

Medium. It is a one-line C++ deletion in JSC bindings, but the PR description traces the mechanism through JSScope::resolve / JSScope::abstractAccess and explains why the deleted hook was redundant for the compiled function (lookups against a chain containing a JSWithScope are marked Dynamic and walk the chain at runtime, hitting the extension before the global). I verified by grep that nothing else in src/ calls setGlobalScopeExtension, so after this change the realm's global scope extension stays null. The remaining globalScopeExtension() reads (NodeVM.cpp:136/443/458, NodeVMScript.cpp:177, NodeVMSourceTextModule.cpp:127) only pick a LexicallyScopedFeatures value for a SourceCodeKey, and the PR's follow-up comment confirms SourceCodeFlags masks that down to the strict-mode bit — so cachedData interop is unchanged.

Other factors

The tests are well-constructed per REVIEW.md guidance: test 1 pins the behavior the function itself relies on (visibility, later-extension-shadows-earlier, closures, direct eval), so the deletion cannot silently regress the intended semantics; tests 2–3 assert the leak is gone across every path the PR enumerates (module typeof, runInThisContext, new Function, indirect eval, an earlier-compiled function, and the parsingContext equivalents). The identifiers fromFirstExtension/fromSecondExtension are unique enough not to collide with the shared main realm the file's other tests run in. The comment-cop feedback about the long code comment was addressed in 280b005. test-vm-basic.js (Node's own contextExtensions/parsingContext/cachedData coverage for compileFunction) is reported passing. The interaction with #38297/#38305 is noted as a trivial rebase, not a semantic conflict.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the second pass. Nothing outstanding from the reviews at this point: the one inline thread (comment-cop, about the code comment) is resolved in 280b005. CI on that sha is still running; the only change since the first green-minus-darwin run is the comment removal, so the fix and tests are as described above.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant