Skip to content

vm: in / hasOwnProperty return false for existing globals when the sandbox is a Proxy (regression in v26.3.0, breaks Jest fake timers) #63739

@brianatdetections

Description

@brianatdetections

Version

v26.3.0 (regression; v26.2.0 and v22.x behave correctly)

Platform

Darwin 25.6.0 arm64 (also expected on all platforms — pure JS/vm behavior)

Subsystem

vm, src (node_contextify)

What steps will reproduce the bug?

Ready-to-run repro: https://github.com/systemtwosecurity/node26-vm-proxy-hasown
(repro.js is exit-code based, suitable for git bisect run; jest-impact/
demonstrates the downstream Jest fake-timers breakage described below.)

'use strict';
const vm = require('node:vm');

const sandbox = new Proxy({}, {});
const ctx = vm.createContext(sandbox);

vm.runInContext(
  'Object.defineProperty(this, "foo", { value: 42, writable: true, enumerable: true, configurable: true });',
  ctx,
);

const r = (code) => vm.runInContext(code, ctx);
console.log('value                   :', r('foo'));
console.log('getOwnPropertyDescriptor:', r('JSON.stringify(Object.getOwnPropertyDescriptor(globalThis, "foo"))'));
console.log('getOwnPropertyNames     :', r('Object.getOwnPropertyNames(globalThis).includes("foo")'));
console.log('hasOwnProperty          :', r('Object.prototype.hasOwnProperty.call(globalThis, "foo")'));
console.log('Object.hasOwn           :', r('Object.hasOwn(globalThis, "foo")'));
console.log('"foo" in globalThis     :', r('"foo" in globalThis'));
console.log('Reflect.has             :', r('Reflect.has(globalThis, "foo")'));

The same inconsistency occurs when the property is defined from the host side
(Object.defineProperty(vm.runInContext('this', ctx), 'foo', …)), and with a
Proxy that has handler traps (e.g. only defineProperty/deleteProperty, as
Jest uses). A plain-object sandbox (vm.createContext({})) is not affected.

How often does it reproduce? Is there a required condition?

100% reproducible. Required condition: the object passed to vm.createContext() is a Proxy.

What is the expected behavior? Why is that the expected behavior?

All reflection operations agree that foo is an own property of the context's
global, as on v26.2.0 and v22.x:

value                   : 42
getOwnPropertyDescriptor: {"value":42,"writable":true,"enumerable":true,"configurable":true}
getOwnPropertyNames     : true
hasOwnProperty          : true
Object.hasOwn           : true
"foo" in globalThis     : true
Reflect.has             : true

What do you see instead?

On v26.3.0, the property is readable, enumerable via getOwnPropertyNames, and
has a full own-property descriptor — yet every membership check denies it:

value                   : 42
getOwnPropertyDescriptor: {"value":42,"writable":true,"enumerable":true,"configurable":true}
getOwnPropertyNames     : true
hasOwnProperty          : false
Object.hasOwn           : false
"foo" in globalThis     : false
Reflect.has             : false

Additional information

Cause

Bisects to #63549 ("src: fix ContextifyContext property definer interception
result", first released in v26.3.0).

Before #63549, ContextifyContext::PropertyDefinerCallback defined the
property on the sandbox and returned Intercepted::kNo, so V8 also
defined it on the real context global. The property existed in both places,
which masked the issue below (at the cost of the double-define bug #52634
that #63549 correctly fixed).

After #63549, the definer returns Intercepted::kYes on success, so the
property exists only on the sandbox Proxy. ContextifyContext::PropertyQueryCallback
then resolves has-type queries via:

Maybe<bool> maybe_has = sandbox->HasRealNamedProperty(context, property);

(https://github.com/nodejs/node/blob/v26.3.0/src/node_contextify.cc#L496)

HasRealNamedProperty does not look through JSProxy, so it reports the
property as absent; the callback falls through to the real context global
(where the property no longer exists since #63549) and returns
Intercepted::kNoin/hasOwnProperty evaluate to false. Meanwhile
PropertyDescriptorCallback resolves through the Proxy and finds the
descriptor, hence the inconsistency.

PropertyGetterCallback/PropertySetterCallback similarly use
GetRealNamedProperty-family APIs but appear to take a different path that
does consult the sandbox (reads/writes work), making the query callback the
outlier.

Real-world impact: this breaks Jest entirely on v26.3.0

jest-environment-node passes a Proxy as the vm sandbox (its GlobalProxy,
used for globals cleanup — all current Jest 29.x/30.x releases). Inside the
Jest sandbox on v26.3.0,
Object.prototype.hasOwnProperty.call(globalThis, 'setTimeout') returns
false. @sinonjs/fake-timers records that flag at install time and, on
jest.useRealTimers(), deletes setTimeout/clearTimeout/etc. from the
sandbox global instead of restoring the originals:

ReferenceError: setTimeout is not defined

Any Jest suite that touches fake timers then hard-crashes its worker
("Jest worker encountered 4 child process exceptions"). We hit this across a
large monorepo; pinning Node to v26.2.0 is the only mitigation (no Jest or
sinon version avoids it, since the flag is read from the affected vm global).

Possibly related: #63715 is another v26 regression in contextify global
property semantics (let redeclaration), though its mechanism may differ.

Metadata

Metadata

Assignees

No one assigned

    Labels

    vmIssues and PRs related to the vm subsystem.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions