Skip to content

node:vm: block sloppy [[Set]] on read-only contextified globals - #34071

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4e86632a/vm-readonly-global-put
Closed

robobun wants to merge 2 commits into
mainfrom
farm/4e86632a/vm-readonly-global-put

Conversation

@robobun

@robobun robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator

What

Inside a node:vm context, a plain sloppy-mode assignment could overwrite non-writable globals:

import * as vm from "node:vm";
const ctx = vm.createContext({});
vm.runInContext("globalThis.NaN = 123; String(globalThis.NaN)", ctx);
// Bun before: "123"  (descriptor still reports writable:false)
// Node:       "NaN"  (silent sloppy-mode no-op)

The strict-mode path threw the expected TypeError, but only after the sandbox had already been mutated, so a subsequent read still saw the overwritten value.

Why

NodeVMGlobalObject::put forwarded every store to the sandbox via sandbox->methodTable()->put(...) without first checking whether the property already exists on the global object as ReadOnly. The extensible sandbox happily acquired a fresh own NaN that then shadowed the non-writable global on the next getOwnPropertySlot.

NodeVMGlobalObject::defineOwnProperty already carries exactly this guard, and Node's contextify PropertySetterCallback intercepts and returns early when the global's own attributes include ReadOnly.

Fix

Before touching the sandbox, look up the property on the global object itself (bypassing the sandbox interception via JSC::JSGlobalObject::getOwnPropertySlot). If it exists with ReadOnly, delegate to Base::put, which applies the ordinary sloppy-mode no-op / strict-mode TypeError semantics and leaves the sandbox untouched.

Tests

Added to the shared testRunInContext harness so they run against runInContext, runInNewContext, Script#runInContext, and Script#runInNewContext:

  • sloppy globalThis.{NaN,undefined,Infinity} = 123 is a no-op; the descriptor stays {writable:false, configurable:false} and the sandbox gains no keys
  • strict globalThis.NaN = 5 throws TypeError and leaves both the global value and the sandbox unmodified

All 97 test/js/node/test/parallel/test-vm-*.js Node compat tests continue to pass.

Note: this overlaps textually with #33486, which also adds a JSGlobalObject::getOwnPropertySlot call in put for a different purpose (sandbox as store for guest-created globals). Whichever lands second will need a small rebase; the two guards are independent.


[stamp-90s] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 68 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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (20b8ba12b)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [21.54ms]
(pass) vm > runInContext() > can return a value [16.30ms]
(pass) vm > runInContext() > can return a complex value [15.47ms]
(pass) vm > runInContext() > can return the last value [16.08ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [16.54ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.19ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [15.80ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [15.76ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [15.49ms]
(pass) vm > runInContext() > new Int16
... (truncated)

release without fix: 68 skipped
bun test v1.4.0-canary.1 (7d0a0ac30)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.39ms]
(pass) vm > runInContext() > can return a value [0.23ms]
(pass) vm > runInContext() > can return a complex value [0.21ms]
(pass) vm > runInContext() > can return the last value [0.18ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.23ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [0.17ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [0.22ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [0.19ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [0.19ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.17ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.17ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.14ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Big
... (truncated)
passes on PR (with fix)
ASAN with fix: 68 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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (20b8ba12b)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [22.28ms]
(pass) vm > runInContext() > can return a value [15.21ms]
(pass) vm > runInContext() > can return a complex value [15.66ms]
(pass) vm > runInContext() > can return the last value [15.49ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [16.71ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.20ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.28ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [15.72ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [15.84ms]
(pass) vm > runInContext() > new Int16
... (truncated)

release with fix: 68 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 712ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 3m 35s
[3/6] link bun-profile
[4/6] bun-profile --re
... (truncated)
diff hotspot
src/jsc/bindings/NodeVM.cpp | 13 +++++++++++++
 test/js/node/vm/vm.test.ts  | 27 +++++++++++++++++++++++++++
 2 files changed, 40 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

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

NodeVMGlobalObject::put forwarded every store to the sandbox before
consulting the global object's own attributes, so a sloppy-mode
`globalThis.NaN = 123` inside a vm context created a fresh writable
`NaN` on the sandbox that shadowed the non-writable global on the next
read. The strict-mode path threw the expected TypeError but only after
the sandbox had already been mutated.

Mirror the guard that defineOwnProperty already has and that Node's
contextify PropertySetterCallback applies: if the property exists on the
global object as ReadOnly, route straight to Base::put so the ordinary
sloppy no-op / strict TypeError semantics apply and the sandbox is left
untouched.
@robobun

robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:38 PM PT - Jul 13th, 2026

✅ @robobun, your commit 20b8ba12bd0d8b76c450b6258c7ea8c9fa158feb passed in Build #72468! 🎉


🧪   To try this PR locally:

bunx bun-pr 34071

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

bun-34071 --bun

@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d417a0e2-d029-4185-a82a-5d0b56515443

📥 Commits

Reviewing files that changed from the base of the PR and between 7d0a0ac and 20b8ba1.

📒 Files selected for processing (1)
  • test/js/node/vm/vm.test.ts

Walkthrough

Read-only global assignments in isolated VM contexts now bypass sandbox forwarding, with tests covering sloppy no-op writes and strict-mode errors.

Changes

Read-only VM globals

Layer / File(s) Summary
Read-only assignment handling
src/jsc/bindings/NodeVM.cpp, test/js/node/vm/vm.test.ts
NodeVMGlobalObject::put routes existing ReadOnly properties through Base::put; isolated VM tests cover sloppy no-op assignments and strict-mode TypeError behavior.

Suggested reviewers: alii, cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change to block sloppy assignments on read-only vm globals.
Description check ✅ Passed It explains the bug, fix, and verification steps, and includes test coverage even though the headings differ from the template.
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.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/vm/vm.test.ts`:
- Around line 423-447: Strengthen both read-only-global tests by asserting that
each tested name is not an own property of the sandbox context, rather than
relying only on Object.keys(context). Add the check for NaN, undefined, and
Infinity alongside the existing value and descriptor assertions, while
preserving the current mutation and strict-assignment expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6be428ea-29fc-44fa-b7d5-78702df1e86b

📥 Commits

Reviewing files that changed from the base of the PR and between a59a9c3 and 7d0a0ac.

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

Comment thread test/js/node/vm/vm.test.ts

@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.

No bugs found, but this touches NodeVMGlobalObject::put in the JSC bindings — vm sandboxing semantics are subtle enough that a human should confirm.

Checked: the new guard mirrors the existing pattern in defineOwnProperty (same qualified JSC::JSGlobalObject::getOwnPropertySlot call to bypass sandbox interception); exception scoping is correct (RETURN_IF_EXCEPTION after the lookup, RELEASE_AND_RETURN on the tail call).
Checked: only ReadOnly is tested here vs. ReadOnly && DontDelete in defineOwnProperty — this matches Node's PropertySetterCallback vs PropertyDefinerCallback split, so the asymmetry is intentional.
Checked: accessor properties won't trip the guard (no ReadOnly bit), so sandbox setters still fire; the notContextified path is unaffected since the guard runs before that branch.

Extended reasoning...

Overview

Adds a 13-line guard to NodeVMGlobalObject::put in src/jsc/bindings/NodeVM.cpp: before forwarding a [[Set]] to the sandbox, check whether the property already exists on the global object itself with the ReadOnly attribute; if so, delegate to Base::put (which applies the standard sloppy no-op / strict TypeError) instead of letting the sandbox acquire a shadowing own property. Two new tests in test/js/node/vm/vm.test.ts run across all four runInContext variants.

Security risks

node:vm is sandboxing-adjacent. This change tightens behavior (prevents mutation of non-writable globals) rather than loosening it, so the security direction is favorable. The lookup uses the base-class JSC::JSGlobalObject::getOwnPropertySlot explicitly, so it cannot be influenced by a hostile sandbox object's own traps. I don't see a way for this to weaken isolation.

Level of scrutiny

Medium-high. The diff is small and closely mirrors the existing defineOwnProperty guard in the same file, and the PR description cites the corresponding Node contextify callback. However, JSC method-table overrides interact in subtle ways (property slot receivers, InternalMethodType, ordering vs. the later isDeclaredOnSandbox lookup), and there's an edge case worth a maintainer's eye: when the sandbox already has an own property of the same name (e.g. createContext({NaN: 42})), writes now become no-ops because the guard consults the global rather than the sandbox — that appears to match Node, but it's the kind of semantic detail a human familiar with this file should confirm.

Other factors

  • The author notes a textual overlap with #33486 that will need a rebase whichever lands second.
  • CodeRabbit's one comment (test assertion strength) was addressed and resolved.
  • Exception-check discipline (RETURN_IF_EXCEPTION / RELEASE_AND_RETURN) looks correct; verified against neighboring code.
  • Node compat suite (test-vm-*.js) reportedly still passes per the PR description.

Given this is C++ JSC bindings in the vm contextification layer, I'm deferring rather than approving.

@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Re the createContext({NaN: 42}) edge case raised above: verified it matches Node after this change.

$ bun -e 'const vm=require("vm"); const ctx=vm.createContext({NaN:42}); console.log(vm.runInContext("globalThis.NaN=123; globalThis.NaN",ctx), ctx.NaN);'
42 42
$ node -e '...'
42 42

The guard consults the global object's own ReadOnly attribute, so the write is blocked regardless of what the sandbox already carries; this is exactly what Node's PropertySetterCallback does via global_proxy()->GetRealNamedPropertyAttributes (which bypasses the interceptor).

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

#39588 reworks how a contextified global resolves against its sandbox and covers this case. Its put rejects a store to a read-only property of the global or of the sandbox before it touches the sandbox. On a build of that branch, sloppy globalThis.NaN = 123 is a no-op, the descriptor still reports writable: false, and the sandbox gains no keys. The strict form throws a TypeError and leaves both untouched. The tests from this PR pass when applied on top of that branch. Closing in favor of #39588.

@robobun robobun closed this Aug 19, 2026
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