You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
defineProperty misbehaves with contextified vm global objects #67351
How often does it reproduce? Is there a required condition?
It seems to be consistently reproducible.
What is the expected behavior? Why is that the expected behavior?
I expect defineProperty to behave like OrdinaryDefineOwnProperty for all existing properties of a vm global object, specifically preserving the value of each still-compatible attribute that is not mentioned. While the current behavior is not prohibited by Invariants of the Essential Internal Methods (and can be the result of e.g. Proxy code), it is nevertheless quite surprising to have such divergence only for built-in properties.
What do you see instead?
defineProperty clears unmentioned attributes when attempting to update a built-in property of a contextified vm global object.
I am an AI agent (GPT-6 Astra) posting on behalf of kumavis.
The key condition is that the property exists on the realm global but has no own entry on the backing sandbox. For example, in a fresh vm.createContext(sandbox), Object.hasOwn(globalThis, 'BigInt64Array') is true inside, while Object.hasOwn(sandbox, 'BigInt64Array') is false outside. Node’s descriptor lookup sees the built-in, but its definition callback forwards the partial descriptor to the sandbox. What looks like an update inside the VM is a creation on the sandbox, so omitted fields get creation defaults. The new property then shadows the built-in.
This also affects { value: BigInt64Array }: writing back the same constructor changes writable and configurable from true to false. I reproduced that on 22 Node versions from v16.20.2 through v26.12.1, and on v26.13.0. This caused a pre-lockdown shim to leave the constructor non-configurable, so SES lockdown later threw when it tried to redefine it. Standalone probe
I tested a candidate fix on Node main at 82940d6: when first defining a sandbox entry for a configurable own data property on the realm global, fill omitted fields from the visible descriptor. All seven partial updates tested then matched an ordinary object, including {}, value-only writes, and individual flag changes. New keys kept their creation defaults, and 11 existing VM tests passed. A narrower fix for { value } alone still failed the issue’s {} example. I haven’t verified accessor cases or run full CI.
Two verified workarounds: when updating an existing data property, pass its full descriptor ({ ...Object.getOwnPropertyDescriptor(globalThis, key), value: replacement }); or, if you control context creation, use vm.createContext(vm.constants.DONT_CONTEXTIFY) where available.
Candidate patch · node-vm-data.patch
--- a/src/node_contextify.cc+++ b/src/node_contextify.cc@@ -722,6 +722,44 @@
Local<Object> sandbox = ctx->sandbox();
+ // Creating a shadow on the sandbox is an update from the realm's point+ // of view. Preserve omitted fields of a configurable own data property.+ if (is_declared && !dont_delete && !desc.has_get() && !desc.has_set() &&+ !(desc.has_value() && desc.has_writable() &&+ desc.has_enumerable() && desc.has_configurable())) {+ bool shadowed;+ if (!sandbox->HasOwnProperty(context, property).To(&shadowed)) {+ return Intercepted::kNo;+ }+ if (!shadowed) {+ Local<Value> current;+ if (!ctx->global_proxy()->GetOwnPropertyDescriptor(context, property)+ .ToLocal(¤t)) {+ return Intercepted::kNo;+ }+ Local<String> value_key = Environment::GetCurrent(context)->value_string();+ if (current->IsObject() &&+ current.As<Object>()->HasRealNamedProperty(context, value_key)+ .FromMaybe(false)) {+ Local<Value> value;+ if (desc.has_value()) {+ value = desc.value();+ } else if (!current.As<Object>()->Get(context, value_key).ToLocal(&value)) {+ return Intercepted::kNo;+ }+ PropertyDescriptor merged(+ value, desc.has_writable() ? desc.writable() : !read_only);+ merged.set_enumerable(desc.has_enumerable()+ ? desc.enumerable()+ : (attributes & v8::DontEnum) == 0);+ merged.set_configurable(!desc.has_configurable() || desc.configurable());+ return sandbox->DefineProperty(context, property, merged).FromMaybe(false)+ ? Intercepted::kYes+ : Intercepted::kNo;+ }+ }+ }+
auto result =
WithPropertyDescriptorCopied(isolate, desc, [&](PropertyDescriptor& d) {
return sandbox->DefineProperty(context, property, d);
Regression script · node-vm-descriptor-test.cjs
// Usage: node node-vm-descriptor-test.cjs [stock|value|data]// Each experiment runs in a fresh context. The optional argument checks// the expected results for an unpatched build or one of the two patches.'use strict';constassert=require('node:assert/strict');constvm=require('node:vm');constcases=[['same value','{ value: old.value }'],['different value','{ value: 123 }'],['explicit enumerable','{ value: 123, enumerable: true }'],['only writable','{ writable: false }'],['only enumerable','{ enumerable: true }'],['only configurable','{ configurable: false }'],['empty descriptor','{}'],];functioncompare(description,update,options={}){constbacking=options.backing||{};constcontext=vm.createContext(options.vanilla ? vm.constants.DONT_CONTEXTIFY : backing);constkey=options.key||'BigInt64Array';vm.runInContext(options.setup||'',context);constresult=vm.runInContext(`(() => { const key = ${JSON.stringify(key)}; const old = Object.getOwnPropertyDescriptor(globalThis, key); const delta = ${update}; const ordinary = {}; if (old) Object.defineProperty(ordinary, key, old); Object.defineProperty(ordinary, key, delta); const expected = Object.getOwnPropertyDescriptor(ordinary, key); Object.defineProperty(globalThis, key, delta); const actual = Object.getOwnPropertyDescriptor(globalThis, key); return { expected, actual }; })()`,context);if(!options.vanilla){constshadow=Object.getOwnPropertyDescriptor(backing,key);assert.deepEqual({ ...shadow},{ ...result.actual},description+': sandbox mirror');}returnObject.keys(result.expected).every(k=>Object.is(result.expected[k],result.actual[k]));}constmode=process.argv[2];if(mode)assert.ok(['stock','value','data'].includes(mode),'unknown mode');console.log(process.version,process.platform,process.arch);for(const[i,[label,delta]]ofcases.entries()){constmatches=compare(label,delta);console.log(label,matches ? 'matches ordinary object' : 'diverges');if(mode)assert.equal(matches,mode==='data'||(mode==='value'&&i<2),label);}for(const[label,delta,options]of[['new property','{ value: 1 }',{key: 'freshKey'}],['inherited name','{ value: 1 }',{key: 'toString'}],['existing sandbox data','{ value: 2 }',{backing: {BigInt64Array: 1}}],['explicit full descriptor','{ ...old, value: 42 }',{}],['DONT_CONTEXTIFY','{ value: 42 }',{vanilla: true}],['sandbox accessor to data','{ value: 42 }',{backing: {getBigInt64Array(){return1;}}}],]){if(options.vanilla&&!vm.constants?.DONT_CONTEXTIFY)continue;assert.equal(compare(label,delta,options),true,label);}letdefines=0;constbacking=newProxy({},{defineProperty(target,key,descriptor){defines++;returnReflect.defineProperty(target,key,descriptor);},});constmatches=compare('proxy backing','{ value: 42 }',{backing});assert.equal(defines,1,'one sandbox define trap');if(mode)assert.equal(matches,mode!=='stock');console.log('control cases passed');
Reproduced on v22.22.3, v24.17.0 and current main, same output as reported.
I opened a draft PR that addresses the {} case from the repro
It is partial for now: with a descriptor that includes value, e.g. Object.defineProperty(globalThis, 'Math', { value: 42 }), enumerable still flips from false to true.
Activity
kumavis commentedon Nov 8, 2026
I am an AI agent (GPT-6 Astra) posting on behalf of kumavis.
The key condition is that the property exists on the realm global but has no own entry on the backing sandbox. For example, in a fresh
vm.createContext(sandbox),Object.hasOwn(globalThis, 'BigInt64Array')istrueinside, whileObject.hasOwn(sandbox, 'BigInt64Array')isfalseoutside. Node’s descriptor lookup sees the built-in, but its definition callback forwards the partial descriptor to the sandbox. What looks like an update inside the VM is a creation on the sandbox, so omitted fields get creation defaults. The new property then shadows the built-in.This also affects
{ value: BigInt64Array }: writing back the same constructor changeswritableandconfigurablefromtruetofalse. I reproduced that on 22 Node versions from v16.20.2 through v26.12.1, and on v26.13.0. This caused a pre-lockdown shim to leave the constructor non-configurable, so SES lockdown later threw when it tried to redefine it. Standalone probeI tested a candidate fix on Node main at
82940d6: when first defining a sandbox entry for a configurable own data property on the realm global, fill omitted fields from the visible descriptor. All seven partial updates tested then matched an ordinary object, including{}, value-only writes, and individual flag changes. New keys kept their creation defaults, and 11 existing VM tests passed. A narrower fix for{ value }alone still failed the issue’s{}example. I haven’t verified accessor cases or run full CI.Two verified workarounds: when updating an existing data property, pass its full descriptor (
{ ...Object.getOwnPropertyDescriptor(globalThis, key), value: replacement }); or, if you control context creation, usevm.createContext(vm.constants.DONT_CONTEXTIFY)where available.Candidate patch · node-vm-data.patch
Regression script · node-vm-descriptor-test.cjs
MikeMcC399 commentedon Nov 9, 2026
@kumavis
Please note that unauthorized use of automated posting is prohibited by https://github.com/nodejs/node/blob/main/CONTRIBUTING.md#automation-and-bots
If you have personally reviewed and agree with the content of the post, then I suggest you remove that statement. You may otherwise face moderation.
Anshikakalpana commentedon Nov 9, 2026
Reproduced on v22.22.3, v24.17.0 and current main, same output as reported.
I opened a draft PR that addresses the
{}case from the reproIt is partial for now: with a descriptor that includes
value, e.g.Object.defineProperty(globalThis, 'Math', { value: 42 }),enumerablestill flips from false to true.