Skip to content

defineProperty misbehaves with contextified vm global objects #67351

Open

Description

@gibson042

Version

v26.13.0

Platform

Ubuntu Linux x86_64 6.8.0-146-generic

Subsystem

vm

What steps will reproduce the bug?

require("node:vm").runInNewContext(`
  const { defineProperty, getOwnPropertyDescriptor: getOwnDesc } = Object;
  const key = "globalThis";
  ({
    before: getOwnDesc(globalThis, key),
    after: getOwnDesc(defineProperty(globalThis, key, {}), key),
  })
`);
/* =>
{
  before: { value: {}, writable: true, enumerable: false, configurable: true },
  after: {
    value: undefined,
    writable: false,
    enumerable: false,
    configurable: false
  }
}
*/

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.

Additional information

No response

Activity

  1. kumavis commented on Nov 8, 2026

    @kumavis

    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(&current)) {
    +        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';
    const assert = require('node:assert/strict');
    const vm = require('node:vm');
    
    const cases = [
      ['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', '{}'],
    ];
    
    function compare(description, update, options = {}) {
      const backing = options.backing || {};
      const context = vm.createContext(options.vanilla ? vm.constants.DONT_CONTEXTIFY : backing);
      const key = options.key || 'BigInt64Array';
      vm.runInContext(options.setup || '', context);
      const result = 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) {
        const shadow = Object.getOwnPropertyDescriptor(backing, key);
        assert.deepEqual({ ...shadow }, { ...result.actual }, description + ': sandbox mirror');
      }
      return Object.keys(result.expected).every(k => Object.is(result.expected[k], result.actual[k]));
    }
    
    const mode = 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]] of cases.entries()) {
      const matches = 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: { get BigInt64Array() { return 1; } }}],
    ]) {
      if (options.vanilla && !vm.constants?.DONT_CONTEXTIFY) continue;
      assert.equal(compare(label, delta, options), true, label);
    }
    let defines = 0;
    const backing = new Proxy({}, {
      defineProperty(target, key, descriptor) {
        defines++;
        return Reflect.defineProperty(target, key, descriptor);
      },
    });
    const matches = 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');
  2. MikeMcC399 commented on Nov 9, 2026

    @MikeMcC399
    Contributor

    @kumavis

    I am an AI agent (GPT-6 Astra) posting on behalf of 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.

  3. Anshikakalpana commented on Nov 9, 2026

    @Anshikakalpana
    Contributor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions