Skip to content

node:vm: check codeGeneration and contextExtensions entries with validateObject like Node - #38371

Open
robobun wants to merge 4 commits into
mainfrom
farm/3466d5c5/vm-codegeneration-validate-object
Open

robobun wants to merge 4 commits into
mainfrom
farm/3466d5c5/vm-codegeneration-validate-object

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • vm.createContext({}, { codeGeneration: [] }) and { codeGeneration: function () {} } are accepted; Node throws ERR_INVALID_ARG_TYPE: The "options.codeGeneration" property must be of type object. Received an instance of Array (or Received function ...). Same for contextCodeGeneration on vm.runInNewContext() and Script#runInNewContext(), which return the script's result instead of throwing.
  • vm.compileFunction("", [], { contextExtensions: [[]] }) and [function () {}] are accepted too (Node: The "options.contextExtensions[0]" property must be of type object. Received an instance of Array), and a bad entry at any index is reported as contextExtensions[0]; Node names the real index.
  • Cause: two option checks in src/jsc/bindings/NodeVM.cpp use JSValue::isObject(), which is true for arrays and functions: the codeGeneration/contextCodeGeneration container in getNodeVMContextOptions() (line 715; all three entry points read the option through it) and the contextExtensions[i] loop in CompileFunctionOptions::fromJS() (line 2131, with a hard-coded [0] in the message). Node's lib/vm.js checks both with validateObject().
  • node:vm: reject array and function options like Node's validateObject #38381 converted the two checks of the options argument itself to V::validateObject() and left these two, which need a name built at runtime, to this PR. After it, these are the last isObject() option checks in the file.
  • Found by comparing against node v26.3.0 while looking at this function; there is no user report, and the vendored Node tests only cover the 1/null cases that were already rejected (test-vm-codegen.js, test-vm-basic.js).

Fix

  • Both checks call V::validateObject() (src/jsc/bindings/NodeValidator.cpp), Bun's native port of Node's validateObject(), with the name built with makeString ("options." + key, "options.contextExtensions[" + i + "]"). null and primitives produce the same error as before; the contextExtensions message now carries the real index.
  • V::validateObject() gets a const WTF::String& overload next to the existing ASCIILiteral one. The two overloads and the JS-facing jsFunction_validateObject (require("internal/validators").validateObject, whose name is a JSValue) now share one implementation, validateObjectImpl<Name>; the name is only converted when an error is thrown, so the 20 existing "..."_s callers do not build a String on the success path.
  • Why this is right: it matches Node. validateObject() rejects null, Array.isArray(value) and functions; V::validateObject() uses JSC's isArray() (the IsArray operation, so a Proxy around an array is rejected too) and isCallable(), and checks for an exception after isArray() because a revoked Proxy throws there (Node throws a TypeError from Array.isArray in that case as well). Error name, code and message are identical to Node's for arrays, named functions, classes, null, primitives and the contextExtensions index. One intentional difference: Node passes null through its contextExtensions check (kValidateObjectAllowNullable) and then dies on a native assertion (Assertion failed: val->IsObject(), node v26.3.0); Bun keeps rejecting it with ERR_INVALID_ARG_TYPE, as it did before.
  • Verified with test/js/node/vm/vm.test.ts: "the codeGeneration option itself is checked like Node's validateObject()" (3 entry points x arrays, function/class/arrow, Proxy-of-array, Proxy-of-function, and the still-rejected null/primitives and still-accepted objects/undefined) and "each contextExtensions entry is checked like Node's validateObject(), under its own index". 10 of the 16 tests fail on bun 1.4.0, all pass with this change; the whole file passes (266).
  • Also run with this change: test-vm-basic.js (asserts the contextExtensions[0] message), test-vm-codegen.js, test-vm-options-validation.js and the other test-vm-*context*/*create* node tests; for the other validateObject users, process-execve.test.ts, test-process-threadCpuUsage-*.js, test-crypto-prime.js, test-crypto-keygen*.js, test-crypto-key-objects.js, test-crypto-dh-stateless.js, test-vm-module-*.js and test-vm-measure-memory*.js (JS-side validateObject); the new tests also pass under BUN_JSC_validateExceptionChecks=1.
  • The Proxy-of-function test only checks the error code: Bun renders a callable Proxy as function without a name (Node prints the target's name), which is a determineSpecificType detail tracked separately.
  • Related open PRs, all independent: node:vm: accept undefined codeGeneration.strings/wasm like Node #38314 changes the nested strings/wasm reads a few lines below the first hunk (its tests sit next to these, so whichever lands second gets a trivial test conflict); node:vm: run runInNewContext() in the sandbox's existing context #38326, node:vm: run DONT_CONTEXTIFY contexts directly against the real global #34623 and node:vm: reject Object.freeze/seal/preventExtensions on a contextified global #33077 each add a JS-side getContextOptions() for vm.runInNewContext() only, while createContext() and Script#runInNewContext() are native and still reach the check fixed here; node:vm: return an existing context from createContext() before validating options #38373 covers createContext() validating options for an already-contextified sandbox.

Background

  • createContext(sandbox, { codeGeneration: { strings, wasm } }) controls whether code in the new context may use eval/new Function and compile WebAssembly; vm.runInNewContext() and Script#runInNewContext() take the same object as contextCodeGeneration. compileFunction(code, params, { contextExtensions: [obj, ...] }) wraps the function in one with-style scope per object. Bun reads the first group in getNodeVMContextOptions() and the second in CompileFunctionOptions::fromJS(), both in NodeVM.cpp.
  • Node's validateObject(value, name) (lib/internal/validators.js) is the check Node applies to option objects: ERR_INVALID_ARG_TYPE for null, non-objects, arrays and functions. Bun::V::validateObject is the native equivalent, used by the node:crypto, process and Worker bindings; jsFunction_validateObject exposes the same check to Bun's built-in JS modules.
  • JSC::isArray(globalObject, value) implements the spec's IsArray: true for arrays and for Proxies whose target is an array, and it throws for a revoked Proxy, hence the throw-scope check right after it. JSValue::isCallable() is the native equivalent of typeof value === "function".
  • ASCIILiteral is WTF's type for a "..."_s string literal; converting one to a WTF::String allocates a small StringImpl header, which is why the literal overload is kept rather than widening the parameter.
Probe: node v26.3.0 vs bun 1.4.0
createContext codeGeneration: []              node: ERR_INVALID_ARG_TYPE   bun: accepted
createContext codeGeneration: function        node: ERR_INVALID_ARG_TYPE   bun: accepted
createContext codeGeneration: class           node: ERR_INVALID_ARG_TYPE   bun: accepted
createContext codeGeneration: Proxy(array)    node: ERR_INVALID_ARG_TYPE   bun: accepted
createContext codeGeneration: null / 1        node: ERR_INVALID_ARG_TYPE   bun: ERR_INVALID_ARG_TYPE
createContext codeGeneration: {} / Object.create(null) / new Date()   both: accepted
runInNewContext contextCodeGeneration: [] / function          node: ERR_INVALID_ARG_TYPE   bun: returns 1
Script#runInNewContext contextCodeGeneration: [] / function   node: ERR_INVALID_ARG_TYPE   bun: returns 1
createContext codeGeneration: revoked Proxy   node: TypeError from IsArray   bun (after): TypeError from Array.isArray

compileFunction contextExtensions: [[]]        node: ...contextExtensions[0]... an instance of Array   bun: accepted
compileFunction contextExtensions: [fn]        node: ...contextExtensions[0]... function ext          bun: accepted
compileFunction contextExtensions: [{}, 1]     node: ...contextExtensions[1]... type number (1)       bun: ...contextExtensions[0]...
compileFunction contextExtensions: [{}, []]    node: ...contextExtensions[1]... an instance of Array  bun: accepted
compileFunction contextExtensions: [null]      node: aborts (Assertion failed: val->IsObject())       bun: ERR_INVALID_ARG_TYPE (unchanged)

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

fails on main (without fix)
ASAN without fix: 10 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 (9bd6acae6)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [38.61ms]
(pass) vm > runInContext() > can return a value [26.39ms]
(pass) vm > runInContext() > can return a complex value [26.98ms]
(pass) vm > runInContext() > can return the last value [26.64ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [29.03ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [16.02ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.94ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [16.88ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [17.45ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [16.13ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [15.69ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [16.22ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release without fix: 62 skipped
bun test v1.4.0-canary.1 (d8a339921)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.36ms]
(pass) vm > runInContext() > can return a value [0.25ms]
(pass) vm > runInContext() > can return a complex value [0.20ms]
(pass) vm > runInContext() > can return the last value [0.22ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.22ms]
(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.26ms]
(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.16ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.18ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.16ms]
(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 (9bd6acae6)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [41.09ms]
(pass) vm > runInContext() > can return a value [28.24ms]
(pass) vm > runInContext() > can return a complex value [28.80ms]
(pass) vm > runInContext() > can return the last value [27.95ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [31.39ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [25.67ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [30.21ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [29.05ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [29.07ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [28.37ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [28.10ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [27.94ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release with fix: 62 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 776ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/17] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/17] gen cpp.rs (cppbind)
[2/17] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_install_types v0.0.0 (/workspace/bun/src/install_types)
�[1m�[92m   Compiling�[0m bun_options_types v0.0.0 (/workspace/bun/src/options_types)
�[1m�[92m   Compiling�[0m bun_crash_handler v0.0.0 (/workspace/bun/src/crash_handler)
�[1m�[92m   Compiling�[0m bun_resolve_builtins v0.0.0 (/workspace/bun/src/resolve_builtins)
�[1m�[92m   Compiling�[0m bun_api v0.0.0 (/workspace/bun/src/api)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_js_printer v0.0.0 (/workspace/bun/src/js_printer)
�[1m�[92m   Compiling�[0m bun_spawn v0.0.0 (/workspace/bun/src/spawn)
�[1m�[92m   Compiling�[0m bu
... (truncated)
diff hotspot
src/jsc/bindings/NodeVM.cpp        | 10 ++---
 src/jsc/bindings/NodeValidator.cpp | 35 ++++++++--------
 src/jsc/bindings/NodeValidator.h   |  1 +
 test/js/node/vm/vm.test.ts         | 81 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 103 insertions(+), 24 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                reads  edits  tests
src/jsc/bindings/NodeVM.cpp             6      3      0
src/jsc/bindings/NodeValidator.cpp      5      5      0
src/jsc/bindings/NodeValidator.h        2      4      0
test/js/node/vm/vm.test.ts              8      9      0

root cause · written by the author bot

The vm option parsing in getNodeVMContextOptions() and in the compileFunction contextExtensions loop only checked JSValue::isObject(), which is true for arrays and functions, so codeGeneration, contextCodeGeneration and each contextExtensions entry accepted values that Node's validateObject() rejects with ERR_INVALID_ARG_TYPE. The fix replaces those checks with the shared V::validateObject() helper, which rejects null, arrays (via isArray, so proxies are covered) and callables using the same option name and "object" type as Node; a WTF::String overload was added so the contextExtensions nam…

@coderabbitai

coderabbitai Bot commented Aug 14, 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: 6d0ed84d-6cc7-45b9-b735-b75f26ac39cf

📥 Commits

Reviewing files that changed from the base of the PR and between 7ba276f and 9bd6aca.

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

Walkthrough

Changes

The pull request centralizes object validation, applies it to nested Node VM options, and adds tests for indexed error paths and accepted or rejected values.

VM validation

Layer / File(s) Summary
Shared object validator
src/jsc/bindings/NodeValidator.*
Object checks now use a shared helper. validateObject accepts both ASCIILiteral and WTF::String names.
VM option integration
src/jsc/bindings/NodeVM.cpp
codeGeneration and each contextExtensions entry now use shared object validation.
VM validation coverage
test/js/node/vm/vm.test.ts
Tests cover indexed context extensions and validation across VM entry points, including proxies, primitives, null, arrays, functions, undefined, and valid objects.

Suggested reviewers: jarred-sumner, cirospaciari, sosukesuzuki

🚥 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 summarizes the main change: validating node:vm codeGeneration and contextExtensions entries with validateObject.
Description check ✅ Passed The description explains the problem, fix, scope, compatibility behavior, and verification results, although it uses different headings from the template.

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. Code is at d8a3399 (9bd6aca is an empty CI re-run).

CI (build 95904): 177 of 179 jobs passed, including both macOS builds and the node:vm tests on every Linux and Windows lane. The two darwin 14 aarch64 - test-bun shards never got a runner and expired after about three hours (they were cancelled in the first run when it was superseded), so the macOS test shards have not run for this PR; the change is platform-independent validation code in the JSC bindings. The flaky-test annotations (node-tls-namedpipes, fetch-leak, issue 08964, test-fs-read-stream-pos, sqlite, resolve, inspect-error-leak, test-cluster-shared-leak) all passed on retry or alone and are unrelated; the first run's only real failure was vendor/elysia/test/response/stream.test.ts on the x64-asan lane, also unrelated and reported separately. I am not pushing further re-runs; a Buildkite rebuild once the macOS runners are back would fill in the two shards.

Reproduced on bun 1.4.0 and on main with vm.createContext({}, { codeGeneration: [] }), vm.runInNewContext("1", {}, { contextCodeGeneration: [] }), new vm.Script("1").runInNewContext({}, { contextCodeGeneration: () => {} }) and vm.compileFunction("", [], { contextExtensions: [{}, []] }): all accepted, while node v26.3.0 throws ERR_INVALID_ARG_TYPE for each (and names contextExtensions[1], where bun said [0]). 10 of the 16 new tests in test/js/node/vm/vm.test.ts fail on bun 1.4.0 and pass with this branch; test-vm-basic.js, test-vm-codegen.js and the other tests listed in the description still pass.

Review feedback is addressed: the validateObject name parameter is not widened (a WTF::String overload shares one implementation with the ASCIILiteral one), and the contextExtensions[i] check, the other remaining isObject() option check in NodeVM.cpp, was converted too.

@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 — swaps the hand-written isObject() check for the existing V::validateObject() helper so arrays/functions are rejected like Node.

What was reviewed:

  • getNodeVMContextOptions(): asObject(codeGenerationValue) on the next line is still safe (validateObject guarantees isObject()); RETURN_IF_EXCEPTION covers the revoked-Proxy throw from JSC::isArray.
  • V::validateObject signature widening ASCIILiteral → const WTF::String&: all ~18 existing "..."_s callers (crypto/*, BunProcess, JSWorker) convert implicitly; ERR::INVALID_ARG_TYPE already has the WTF::String& name overload.
  • Tests: 3 entry points × {array, function/class/arrow, Proxy, null/primitives, valid objects/undefined} with exact name/code/message assertions.
Extended reasoning...

Overview

Two-line fix in src/jsc/bindings/NodeVM.cpp replacing a hand-written !codeGenerationValue.isObject() + ERR::INVALID_ARG_TYPE with a call to the existing V::validateObject() helper (Node's validateObject() port), so codeGeneration/contextCodeGeneration reject arrays and functions the way Node does. The V::validateObject signature in NodeValidator.{h,cpp} widens its name parameter from ASCIILiteral to const WTF::String& so the caller can pass makeString("options."_s, codeGenerationKey). ~70 lines of new tests in test/js/node/vm/vm.test.ts.

Security risks

None. This tightens option validation on a Node compat surface; no new code paths that read/allocate/execute anything.

Level of scrutiny

Low. This is a targeted Node.js compatibility fix that reuses an existing, already-tested in-tree helper at the shared layer (getNodeVMContextOptions() serves all three entry points). The only non-mechanical piece is the parameter-type widening, which I checked: ASCIILiteral implicitly converts to WTF::String, and grepping shows ~18 existing call sites all pass "..."_s literals. ERR::INVALID_ARG_TYPE (called inside validateObject) already has a const WTF::String& name overload (ErrorCode.h:89), so the body needs no change.

Other factors

  • Exception handling is correct: V::validateObject calls JSC::isArray (throws on revoked Proxy) and checks the scope; the new call site follows with RETURN_IF_EXCEPTION(scope, ). The subsequent asObject(codeGenerationValue) is safe because validateObject only returns cleanly when value.isObject() and it's not an array/callable — still an object.
  • The undefined case is handled by the pre-existing early return above the changed lines, so validateObject never sees it (matching Node, where the option is optional).
  • Test coverage is thorough per REVIEW.md: covers all three sibling entry points via describe.each, asserts exact error class/code/message (not bare toThrow()), includes Proxy-of-array/Proxy-of-function edge cases, and re-asserts the previously-working null/primitive rejections and valid-object acceptances haven't regressed.
  • PR description states the new tests were verified under BUN_JSC_validateExceptionChecks=1 and that the other V::validateObject callers' tests still pass.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:vm: run runInNewContext() in the sandbox's existing context #38326 - Adds a JS-side getContextOptions() in src/js/node/vm.ts calling validateObject(contextCodeGeneration, "options.contextCodeGeneration"), which already makes arrays/functions throw ERR_INVALID_ARG_TYPE for vm.runInNewContext(), and it edits the same getNodeVMContextOptions() call site and vm.test.ts region.
  2. node:vm: run DONT_CONTEXTIFY contexts directly against the real global #34623 - Adds the same validateObject(contextCodeGeneration, ...) line in its own getContextOptions() helper (matching Node's lib/vm.js getContextOptions), covering the same runInNewContext entry point plus overlapping NodeVM.cpp/vm.test.ts changes.
  3. node:vm: reject Object.freeze/seal/preventExtensions on a contextified global #33077 - Also adds validateObject(codeGeneration, "options.contextCodeGeneration") in a getContextOptions() helper, behaviorally identical for arrays/functions on runInNewContext (though only when contextCodeGeneration !== undefined).

🤖 Generated with Claude Code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #38326, #34623 or #33077. Each of those adds a JS-side getContextOptions() in vm.ts that is only wired into vm.runInNewContext(), so they make that one entry point throw as a side effect. The other two entry points never go through vm.ts: createContext() reads options.codeGeneration natively, and Script is the native class exported directly, so Script#runInNewContext() reads options.contextCodeGeneration natively. Both still hit the isObject() check in getNodeVMContextOptions(), which none of the three PRs modify (none has a hunk in that function; their NodeVM.cpp changes are in createContext, getGlobalObjectFromContext, the global object's property hooks, etc.).

On current main, with any of those three applied, vm.createContext({}, { codeGeneration: [] }) and new vm.Script("1").runInNewContext({}, { contextCodeGeneration: [] }) would still be accepted. This PR fixes the shared native check, so all three entry points reject arrays and functions, and it composes with those PRs: once one of them lands, vm.runInNewContext() throws from JS with the same message, which is also what Node does, and the tests here still hold.

dylan-conway pushed a commit that referenced this pull request Aug 14, 2026
…#38381)

### Problem
- `vm.createContext({}, [])`, `vm.createContext({}, function () {})`,
`new vm.Script("1", [])`, `vm.compileFunction("1", [], [])`,
`vm.runInThisContext("1", [])`, `script.runInThisContext([])`,
`script.runInContext(ctx, [])` and `script.runInNewContext({}, [])` are
all accepted. Node throws from every one of them: `TypeError
[ERR_INVALID_ARG_TYPE]: The "options" argument must be of type object.
Received an instance of Array` (`Received function foo` for a function).
- Cause: both native checks of the `options` argument use
`JSValue::isObject()`, which is true for arrays and functions:
`vmModule_createContext()` and `BaseVMOptions::fromJS()` in
`src/jsc/bindings/NodeVM.cpp`. Node's lib/vm.js checks the same argument
with `validateObject(options, 'options')`.
- `null` and primitives were already rejected by those checks, so arrays
and functions were the only values slipping through.

### Fix
- Both checks now call `Bun::V::validateObject()`
(`src/jsc/bindings/NodeValidator.cpp`), the native port of Node's
`validateObject()` that `BunProcess.cpp` already uses. The error has
Node's code and message; a Proxy around an array is reported as `an
instance of Array` too, like `Array.isArray`.
- `vm.runInContext()` and `vm.runInNewContext()` spread `options` into a
fresh object, as lib/vm.js does, so they still accept any non-string
options value. Node accepts `[]`, a function, `null` and `1` there;
before this change the two wrappers handed the value straight to
`Script`, so they rejected `null`/`1` and would have started rejecting
arrays and functions as well.
- `Script#runInNewContext()` is left in Node's order: context options
are read and the context is created, then `runInContext()` rejects the
value. `getNodeVMContextOptions()` already tolerates non-objects the way
Node's `getContextOptions()` does, so it needed no change.
- Why this is right: every entry point whose `options` reaches
`validateObject()` in lib/vm.js (`createContext`, the `Script`
constructor, `getRunInContextArgs()` behind the three run methods,
`compileFunction`) now rejects exactly what it rejects, and the two
wrappers whose `options` never reaches it still accept everything. The
whole matrix below was checked against node v26.3.0; the only remaining
differences are the two pre-existing ones noted there, which #38373
covers and which are about the context argument, not `options`.
- Verified with `test/js/node/vm/vm.test.ts` (`the options argument`):
36 cases, 22 fail on the current build (arrays, proxied arrays and
functions across the 7 entry points, plus the wrapper case), all pass
with the fix.
- All 97 `test/js/node/test/parallel/test-vm-*` files and the 3
sequential ones pass with the fix; the rest of `test/js/node/vm` passes
as well.

### Background
- Node's `validateObject(value, name)` (lib/internal/validators.js)
throws `ERR_INVALID_ARG_TYPE` for `null`, anything `Array.isArray()`
accepts, functions, and non-objects. `Bun::V::validateObject` implements
the same four checks natively (`isNull`, `JSC::isArray`, `isCallable`,
`!isObject`).
- `BaseVMOptions::fromJS()` parses the options shared by all scripts
(`filename`, `lineOffset`, `columnOffset`). `ScriptOptions::fromJS` (the
`Script` constructor), `RunningScriptOptions::fromJS`
(`runInThisContext`, `runInContext`, `runInNewContext`) and
`CompileFunctionOptions::fromJS` (`compileFunction`) all call it first,
so it is the one place the `options` argument is type-checked for those
entry points.
- lib/vm.js's `runInContext()` and `runInNewContext()` never pass the
caller's value on: they build a new object with `{ ...options }` (and
`runInNewContext()` derives the context options from it separately).
That is why `vm.runInNewContext("1", {}, [])` works in Node while `new
vm.Script("1").runInNewContext({}, [])` throws.

Related open PRs, all independent of this one: #38371 (same change for
`options.codeGeneration`), #38373 (`createContext()` returns an existing
context before looking at `options`), #38326 (`Script#runInNewContext()`
reusing an existing context). #38317 also copies `options` in the two
wrappers, there in order to attach the parsing context to the copy; the
native check in this PR is what makes the copy matter for validation,
and whichever of the two lands second only has to drop its duplicate
copy line.

<details>
<summary>node v26.3.0 vs bun, options argument matrix</summary>

`ok` means the call succeeded; otherwise the error code is shown.
`existing` is a sandbox that was already passed to `createContext()`.

| call | node | bun before | bun after |
| --- | --- | --- | --- |
| `createContext({}, [])` | ERR_INVALID_ARG_TYPE | ok |
ERR_INVALID_ARG_TYPE |
| `createContext({}, function () {})` | ERR_INVALID_ARG_TYPE | ok |
ERR_INVALID_ARG_TYPE |
| `createContext({}, null)` / `createContext({}, 1)` |
ERR_INVALID_ARG_TYPE | ERR_INVALID_ARG_TYPE | ERR_INVALID_ARG_TYPE |
| `createContext(undefined, [])` / `createContext(DONT_CONTEXTIFY, [])`
| ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `new Script("1", [])` / `new Script("1", function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `compileFunction("1", [], [])` / `(..., function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `vm.runInThisContext("1", [])` / `(..., function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `script.runInThisContext([])` / `(function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `script.runInContext(existing, [])` / `(..., function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `script.runInNewContext({}, [])` / `(..., function () {})` |
ERR_INVALID_ARG_TYPE | ok | ERR_INVALID_ARG_TYPE |
| `vm.runInContext("1", existing, [])` / function | ok | ok | ok |
| `vm.runInNewContext("1", {}, [])` / function | ok | ok | ok |
| `vm.runInContext("1", existing, 1)` / `null` / `true` / symbol | ok |
ERR_INVALID_ARG_TYPE | ok |
| `vm.runInNewContext("1", {}, 1)` / `null` / `true` / symbol | ok |
ERR_INVALID_ARG_TYPE | ok |

Unchanged pre-existing differences, not about `options`:

| call | node | bun |
| --- | --- | --- |
| `createContext(existing, [])` | ok (returns before validating options)
| ERR_INVALID_ARG_TYPE (#38373) |
| `createContext(function () {}, {})` | ERR_INVALID_ARG_TYPE (`"object"`
argument) | ok |

</details>
@robobun

robobun commented Aug 14, 2026 •

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

❌ @robobun, your commit 9bd6aca has some failures in Build #95904 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38371

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

bun-38371 --bun

…eration

getNodeVMContextOptions() only checked isObject() on the codeGeneration
container, so an array or a function was accepted where Node's
validateObject() throws ERR_INVALID_ARG_TYPE. Use V::validateObject(),
whose name parameter now takes a WTF::String so the option name can be
built from the codeGeneration/contextCodeGeneration key.
…way; keep the ASCIILiteral validateObject overload

validateObject() keeps its ASCIILiteral overload and gains a WTF::String one;
both (and the JS-facing host function) share one implementation, so callers
passing literals do not build a String on the success path.

The contextExtensions[i] check in CompileFunctionOptions::fromJS() was the
other isObject() check standing in for Node's validateObject(); it now uses
the helper too, and reports the offending index instead of always [0].
@robobun
robobun force-pushed the farm/3466d5c5/vm-codegeneration-validate-object branch from f5cac30 to d6b8806 Compare August 14, 2026 10:38
Comment thread src/jsc/bindings/NodeValidator.cpp Outdated
@robobun robobun changed the title node:vm: reject arrays and functions as codeGeneration/contextCodeGeneration node:vm: check codeGeneration and contextExtensions entries with validateObject like Node Aug 14, 2026

@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 — tightens two node:vm option checks to match Node's validateObject() semantics.

What was reviewed:

  • The two NodeVM.cpp call sites: RETURN_IF_EXCEPTION follows each new V::validateObject call, and asObject(codeGenerationValue) remains safe since validateObject only returns success for real objects.
  • validateObjectImpl<Name> refactor: verified ERR::INVALID_ARG_TYPE has overloads for JSValue, WTF::String (and ASCIILiteral via implicit conversion, error-path only), so all three instantiations resolve; behavior of the pre-existing ASCIILiteral and jsFunction_validateObject paths is unchanged.
  • Tests cover all three entry points × arrays/functions/proxies/primitives/null and the per-index contextExtensions message; still-accepted cases ({}, Object.create(null), undefined) are asserted too.
Extended reasoning...

Overview

Two option checks in src/jsc/bindings/NodeVM.cpp that used JSValue::isObject() (which accepts arrays and functions) are replaced with V::validateObject(), matching Node's lib/vm.js. The contextExtensions[i] message now carries the real index instead of a hard-coded [0]. To support runtime-built names, NodeValidator.{h,cpp} gains a const WTF::String& overload of V::validateObject; the two overloads and jsFunction_validateObject are collapsed into one static validateObjectImpl<Name> template so the logic isn't duplicated three ways. Tests in test/js/node/vm/vm.test.ts cover the full value/entry-point matrix.

Security risks

None. This strictly tightens input validation to match Node; nothing is loosened. The one intentional divergence (still rejecting null in contextExtensions where Node aborts on a native assertion) is the safer choice and unchanged from before.

Level of scrutiny

Low–medium. It's a small Node-compat validation fix following the exact pattern already landed in #38381 for the options argument itself in the same file. The NodeValidator refactor is a pure de-duplication — I confirmed ERR::INVALID_ARG_TYPE has JSValue and const WTF::String& name overloads (ErrorCode.h), so all three template instantiations compile and the ASCIILiteral success path stays allocation-free. Exception handling is correct: each new validateObject is followed by RETURN_IF_EXCEPTION with the right sentinel (void return in getNodeVMContextOptions, {} = false in CompileFunctionOptions::fromJS).

Other factors

No CODEOWNERS for these files. The comment-cop bot's inline note was addressed in d8a3399 (the explanatory comment on validateObjectImpl was dropped). The PR description documents that vendored Node tests (test-vm-basic.js, test-vm-codegen.js, test-vm-options-validation.js) and the other native validateObject callers' tests still pass, and that the new tests pass under BUN_JSC_validateExceptionChecks=1. The overlap with #38326/#34623/#33077 is JS-side-only for vm.runInNewContext and composes cleanly with this native fix.

@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 — focused Node-compat validation fix following the same pattern as #38381.

What was reviewed:

  • The two isObject() → V::validateObject() conversions in NodeVM.cpp: both followed by RETURN_IF_EXCEPTION, and asObject(codeGenerationValue) after the check is still safe since validateObject rejects everything non-object.
  • The validateObjectImpl<Name> template: byte-identical logic to the two functions it replaces; verified ERR::INVALID_ARG_TYPE has overloads for all three name types (JSValue, WTF::String, and ASCIILiteral→String), so each instantiation compiles to the same error path as before.
  • makeString now runs on the success path at the two new call sites — unavoidable for a runtime-built name, and this is cold option-parsing code; the ~20 existing ASCIILiteral callers are unaffected.
  • Tests cover the full matrix (3 entry points × arrays/functions/classes/arrows/proxies/null/primitives + positive cases) with exact code/name/message assertions, and the contextExtensions[i] index fix.
Extended reasoning...

Overview

Converts the last two JSValue::isObject() option checks in src/jsc/bindings/NodeVM.cpp to V::validateObject(), so codeGeneration/contextCodeGeneration (in getNodeVMContextOptions(), shared by createContext, vm.runInNewContext, Script#runInNewContext) and each contextExtensions[i] entry (in CompileFunctionOptions::fromJS()) now reject arrays and functions with ERR_INVALID_ARG_TYPE, matching Node's validateObject(). Also fixes the hardcoded contextExtensions[0] index to the real i. To support runtime-built names, adds a const WTF::String& overload of V::validateObject and folds all three overloads (JSValue / ASCIILiteral / String) into one validateObjectImpl<Name> template. 81 lines of tests in vm.test.ts.

Security risks

None. This tightens input validation on a Node-compat surface. No new codepaths accept previously-rejected input; the only behavioral change is that arrays/functions passed as these options now throw instead of being silently treated as {}. The contextExtensions: [null] case (which Node lets through and then aborts on a native assertion) continues to be rejected cleanly, unchanged from before.

Level of scrutiny

Low-to-medium. This is a small (~24 native lines) Node-compat validation change that follows the exact pattern of #38381, which already landed and converted the sibling options-argument checks. The validateObjectImpl refactor touches a helper used by ~20 other callers (crypto, process, Worker), but the body is line-for-line identical to the two functions it replaces — only the name-parameter type is templated, and I confirmed ERR::INVALID_ARG_TYPE in ErrorCode.h has matching overloads for each instantiation. Exception discipline is preserved: isArray (which can throw on a revoked Proxy) is still followed by RETURN_IF_EXCEPTION, and both new NodeVM.cpp call sites check the scope after validateObject before calling asObject(). The description reports the change was run under BUN_JSC_validateExceptionChecks=1 and against the vendored test-vm-* and other validateObject-consumer test files.

Other factors

  • Tests are strong: they cover the full variant matrix per REVIEW.md guidance (all three entry points, arrays, named/arrow/class functions, Proxy-of-array, Proxy-of-function, null, primitives, and the still-accepted plain-object/Object.create(null)/undefined cases), assert exact name/code/message, and verify the real-index fix for contextExtensions. The Proxy-of-function case sensibly asserts only the code, with a comment explaining the known determineSpecificType formatting divergence.
  • makeString for the option name now runs on the success path too, but only at these two new call sites (rarely-used options); the ASCIILiteral overload is kept precisely so the existing hot callers don't allocate.
  • The comment-cop bot's feedback (paragraph-long comment on the template) was addressed in d8a3399 and the thread is resolved.
  • The find-duplicate-prs bot flagged three overlapping PRs; the author's response is correct — those only cover the JS-side vm.runInNewContext path, while this PR fixes the shared native check that createContext and Script#runInNewContext also reach. They compose cleanly.

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