diff --git a/changelog.d/10647-object-prototype-dunder-proto.md b/changelog.d/10647-object-prototype-dunder-proto.md new file mode 100644 index 0000000000..c1ecf83dc4 --- /dev/null +++ b/changelog.d/10647-object-prototype-dunder-proto.md @@ -0,0 +1,4 @@ +### Fixed + +- `Object.prototype` now has a real, spec-shaped `__proto__` accessor (`{ get, set, enumerable: false, configurable: true }`), so `hasOwnProperty`, `Object.hasOwn`, `Object.getOwnPropertyNames`, `Object.getOwnPropertyDescriptor`, `Reflect.ownKeys`, and `"__proto__" in obj` all agree with Node about it — closing a prototype-pollution guard bypass in libraries (e.g. `qs`) that use `hasOwnProperty.call(Object.prototype, key)` to reject `"__proto__"` as a key. +- Fixed a related bug the new accessor exposed: reading `.__proto__` on a `Number`/`String` primitive via a dynamic property access (`(5).__proto__`) could invoke an accessor inherited from `Object.prototype` with `this` bound to the intermediate builtin prototype (`Number.prototype`) instead of the original primitive, answering `Object.prototype` instead of `Number.prototype`. diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index cdaf2e0b5b..34c32c22e2 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -637,7 +637,25 @@ pub(crate) unsafe fn primitive_builtin_prototype_property( } } } + // #10482: the direct-accessor short-circuit just above only covers an + // accessor installed ON `proto_ptr` itself (`Number.prototype`). A key + // inherited from FURTHER up the chain — `Object.prototype.__proto__`, + // now a real accessor — resolves through this generic fallback instead, + // which recurses into `js_object_get_field_by_name(proto_ptr, key)`. + // That recursive walk finds the accessor on the ancestor and invokes it, + // but with no override in place it binds `this` to whichever prototype + // object the walk was probing (`Number.prototype`) rather than the + // original primitive `receiver` — so `(5).__proto__` was answering + // `Object.getPrototypeOf(Number.prototype)` (`Object.prototype`) instead + // of `Object.getPrototypeOf(5)` (`Number.prototype`). Stash the real + // receiver in the same thread-local override + // `resolve_inherited_field_from_prototype` uses for the identical + // problem one level up, so `invoke_accessor_getter` (reached from + // inside the recursive call) picks it up via `ACCESSOR_RECEIVER_OVERRIDE` + // instead of the prototype object it was handed. + let prev_override = accessor_receiver_override_begin(receiver); let value = js_object_get_field_by_name(proto_ptr, key); + accessor_receiver_override_end(prev_override); if value.is_undefined() { return None; } diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index 29f60e3d82..e251cbcce7 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -67,6 +67,109 @@ fn install_array_iterator_symbol(proto_obj: *mut ObjectHeader, value: f64) { ); } +/// #10482: install `Object.prototype.__proto__` as a REAL accessor +/// descriptor — `{ get, set, enumerable: false, configurable: true }`, per +/// ECMA-262 Annex B §B.3.1 — instead of the purely behavioral special-casing +/// Perry had before (reads/writes worked through `__proto__` as a magic key +/// name in several call sites, but nothing on `Object.prototype` reflected +/// it). `hasOwnProperty`/`Object.hasOwn`/`getOwnPropertyNames`/ +/// `getOwnPropertyDescriptor`/`"__proto__" in {}`/`Reflect.ownKeys` all read +/// `ACCESSOR_DESCRIPTORS`/`PROPERTY_DESCRIPTORS` unconditionally, so a real +/// entry here is what makes them agree with Node. +/// +/// Uses `set_builtin_accessor_descriptor` (gate-neutral): it does not flip +/// `GLOBAL_DESCRIPTORS_IN_USE` / `ACCESSORS_IN_USE`, so ordinary property +/// read/write fast paths are unaffected for every OTHER key. `__proto__` +/// itself was already treated as unconditionally interceptable by +/// `object_proto_may_intercept_key` / `plain_custom_prototype_may_intercept` +/// (see `object/descriptor_state.rs`) before this change, so installing a +/// real descriptor for it changes no hot-path gate this key didn't already +/// trip — only what reflection sees. +/// +/// The getter delegates to `js_object_get_prototype_of`, which already +/// implements the getter's exact spec shape (ToObject-style wrapper +/// resolution for primitives, Proxy/Temporal/handle receivers, and a throw +/// on `null`/`undefined`). The setter delegates to +/// `proxy::legacy_dunder_proto_set`, the same Annex-B logic `proxy.rs`'s +/// `ordinary_set_with_receiver` used to inline for this one key (#6828) — +/// now shared so both call sites can never drift apart. Once this +/// descriptor exists, `own_set_descriptor` finds it and dispatches through +/// the ordinary accessor-setter path before that inlined special case is +/// ever reached (see the comment there). +fn install_object_prototype_dunder_proto(proto_obj: *mut ObjectHeader) { + if proto_obj.is_null() { + return; + } + let getter = crate::closure::js_closure_alloc( + object_prototype_dunder_proto_getter_thunk as *const u8, + 0, + ); + let setter = crate::closure::js_closure_alloc( + object_prototype_dunder_proto_setter_thunk as *const u8, + 0, + ); + if getter.is_null() || setter.is_null() { + return; + } + crate::closure::js_register_closure_arity( + object_prototype_dunder_proto_getter_thunk as *const u8, + 0, + ); + crate::closure::js_register_closure_arity( + object_prototype_dunder_proto_setter_thunk as *const u8, + 1, + ); + super::super::native_module::set_bound_native_closure_name(getter, "get __proto__"); + super::super::native_module::set_bound_native_closure_name(setter, "set __proto__"); + super::super::native_module::set_builtin_closure_length(getter as usize, 0); + super::super::native_module::set_builtin_closure_length(setter as usize, 1); + super::super::native_module::set_builtin_closure_non_constructable(getter as usize); + super::super::native_module::set_builtin_closure_non_constructable(setter as usize); + let get_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); + let set_bits = crate::value::js_nanbox_pointer(setter as i64).to_bits(); + // A descriptor alone doesn't make the name enumerable by + // `getOwnPropertyNames`/`hasOwnProperty`/`Object.hasOwn`/ + // `Reflect.ownKeys` — those walk the object's OWN KEYS ARRAY, which + // `set_builtin_accessor_descriptor` (deliberately gate-neutral) never + // touches. Write an ordinary placeholder field first, exactly like + // `perf_hooks::install_perf_getter`: this appends `"__proto__"` to the + // keys array via the ordinary field-set path, and the accessor + // descriptor installed right after takes over every actual read/write — + // the placeholder `undefined` is never observed. + let key = crate::string::js_string_from_bytes(b"__proto__".as_ptr(), 9); + js_object_set_field_by_name(proto_obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + super::super::set_builtin_accessor_descriptor( + proto_obj as usize, + "__proto__".to_string(), + super::super::AccessorDescriptor { + get: get_bits, + set: set_bits, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); +} + +extern "C" fn object_prototype_dunder_proto_getter_thunk( + _closure: *const crate::closure::ClosureHeader, +) -> f64 { + // Spec (Annex B §B.3.1 `get __proto__`): `ToObject(this).[[GetPrototypeOf]]()`. + // `js_object_get_prototype_of` already implements exactly this shape — + // wrapper-prototype resolution for primitives, Proxy/Temporal/handle + // receivers, and a throw on `null`/`undefined` (the `ToObject` failure + // case) — so the getter is a direct delegation, not a reimplementation. + let receiver = crate::object::js_implicit_this_get(); + crate::object::js_object_get_prototype_of(receiver) +} + +extern "C" fn object_prototype_dunder_proto_setter_thunk( + _closure: *const crate::closure::ClosureHeader, + value: f64, +) -> f64 { + let receiver = crate::object::js_implicit_this_get(); + crate::proxy::legacy_dunder_proto_set(receiver, value); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: *mut ObjectHeader) { if proto_obj.is_null() { return; @@ -360,6 +463,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: object_prototype_property_is_enumerable_thunk as *const u8, 1, ); + install_object_prototype_dunder_proto(proto_obj); } "Function" => { // `Function.prototype` has own `length` (0) and `name` ("") data diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f95d8286d6..b9094b1c92 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -767,6 +767,30 @@ fn reflect_value_is_symbol(value: f64) -> bool { && unsafe { crate::symbol::js_is_symbol(value) != 0 } } +/// #6828/#10482: Annex B §B.3.1 `set __proto__` semantics — shared by the +/// real accessor descriptor installed on `Object.prototype` +/// (`object/global_this/proto_methods.rs`'s setter closure, reached via +/// `own_set_descriptor` + `call_setter_with_receiver` above) and this +/// function's own caller (the walk's defensive fallback for the same key). +/// Both must behave identically, so both call this one implementation +/// instead of keeping the logic written out twice. +/// +/// Per spec: a non-object/non-null `value` or a non-object `receiver` is +/// silently ignored (no throw, unlike `Object.setPrototypeOf`); a genuine +/// `[[SetPrototypeOf]]` failure (cyclic / non-extensible) still throws via +/// `js_object_set_prototype_of` itself, matching `Object.setPrototypeOf`'s +/// failure behavior for that case. +pub(crate) fn legacy_dunder_proto_set(receiver: f64, value: f64) { + let value_bits = value.to_bits(); + let valid_proto = value_bits == TAG_NULL + || lookup(value).is_some() + || crate::object::class_ref_id(value).is_some() + || unsafe { crate::object::value_is_object_like(value) }; + if valid_proto && reflect_value_is_object(receiver) { + crate::object::js_object_set_prototype_of(receiver, value); + } +} + /// Is `value` a Reflect-acceptable object? Heap objects, class refs (callable /// constructors), and proxies all count. Primitives / null / undefined do not. pub(crate) fn reflect_value_is_object(value: f64) -> bool { @@ -2207,31 +2231,28 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) } }; } - // #6828: `%Object.prototype%.__proto__` is a legacy accessor whose - // setter performs `SetPrototypeOf(Receiver, value)`. Perry exposes the - // getter intrinsically but does not materialize the built-in accessor - // in the ordinary descriptor table, so model it at the exact point in - // the [[Set]] walk where that descriptor would be found. + // #6828/#10482: `%Object.prototype%.__proto__` is a legacy accessor + // whose setter performs `SetPrototypeOf(Receiver, value)`. + // `object/global_this/proto_methods.rs` now materializes it as a + // REAL accessor descriptor on `Object.prototype` (#10482), so + // `own_set_descriptor` just above finds it and dispatches through + // `call_setter_with_receiver` before the walk ever reaches here — + // this arm is kept as a fallback for a walk that reaches + // `Object.prototype` without ever consulting the descriptor table + // (defensive; not known to be reachable). Both arms must behave + // identically, so both call the one shared implementation. // // Keep this AFTER `own_set_descriptor`: a user-installed own // `__proto__` data/accessor property on an object earlier in the chain // must win. A null-prototype receiver never reaches the canonical // Object.prototype and therefore still creates an ordinary own data - // property. Per Annex B, a primitive RHS is ignored rather than - // throwing (unlike `Object.setPrototypeOf`). + // property. let current_addr = extract_pointer(current.to_bits()) as usize; if current_addr != 0 && current_addr == crate::array::object_prototype_addr() && key_to_rust_string(key).as_deref() == Some("__proto__") { - let value_bits = value.to_bits(); - let valid_proto = value_bits == TAG_NULL - || lookup(value).is_some() - || crate::object::class_ref_id(value).is_some() - || unsafe { crate::object::value_is_object_like(value) }; - if valid_proto && reflect_value_is_object(receiver) { - crate::object::js_object_set_prototype_of(receiver, value); - } + legacy_dunder_proto_set(receiver, value); return true; } if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) { diff --git a/test-files/test_gap_10482_object_prototype_dunder_proto.ts b/test-files/test_gap_10482_object_prototype_dunder_proto.ts new file mode 100644 index 0000000000..5f945c9a3b --- /dev/null +++ b/test-files/test_gap_10482_object_prototype_dunder_proto.ts @@ -0,0 +1,186 @@ +// #10482: Object.prototype has no own __proto__ accessor, so +// hasOwnProperty/Object.hasOwn/getOwnPropertyNames/getOwnPropertyDescriptor +// disagree with Node about it, and a `hasOwnProperty.call(Object.prototype, +// key)` prototype-pollution guard (the qs idiom) lets "__proto__" through. +// +// Node has `Object.prototype.__proto__` as a real accessor property: +// { get: [Function], set: [Function], enumerable: false, configurable: true }. + +const has = Object.prototype.hasOwnProperty; + +// --- Descriptor shape ------------------------------------------------- +const d = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__"); +console.log( + "descriptor shape:", + typeof d?.get, + typeof d?.set, + d?.enumerable, + d?.configurable, + d ? "value" in d : false, +); + +// --- Reflection entry points must agree -------------------------------- +console.log("hasOwnProperty.call:", has.call(Object.prototype, "__proto__")); +console.log("Object.hasOwn:", Object.hasOwn(Object.prototype, "__proto__")); +console.log( + "getOwnPropertyNames includes:", + Object.getOwnPropertyNames(Object.prototype).includes("__proto__"), +); +console.log( + "Reflect.ownKeys includes:", + Reflect.ownKeys(Object.prototype).includes("__proto__"), +); +console.log('"__proto__" in {}:', "__proto__" in {}); + +// --- Enumerability: accessor is non-enumerable ------------------------- +console.log( + "Object.keys(Object.prototype) excludes it:", + !Object.keys(Object.prototype).includes("__proto__"), +); +console.log( + "Object.entries(Object.prototype) excludes it:", + !Object.entries(Object.prototype).some(([k]) => k === "__proto__"), +); +console.log( + "propertyIsEnumerable:", + Object.prototype.propertyIsEnumerable.call(Object.prototype, "__proto__"), +); +{ + let sawIt = false; + for (const k in {}) { + if (k === "__proto__") sawIt = true; + } + console.log("for-in over {} excludes it:", !sawIt); +} + +// --- The qs-style prototype-pollution guard idiom ----------------------- +const keys = ["__proto__", "toString", "b"]; +console.log( + "guarded keys (qs idiom):", + keys.filter((k) => !has.call(Object.prototype, k)).join(","), +); + +// --- Behavioural read/write must still work ----------------------------- + +// Plain object. +{ + const target: any = { inherited: "yes" }; + const plain: any = {}; + plain.__proto__ = target; + console.log( + "plain object: read===write target, getPrototypeOf agrees:", + plain.__proto__ === target, + Object.getPrototypeOf(plain) === target, + ); +} + +// Object.create(null): no legacy setter on the chain, so assignment +// creates an ordinary OWN enumerable data property instead of reparenting. +{ + const target: any = { inherited: "yes" }; + const nullProto: any = Object.create(null); + nullProto.__proto__ = target; + console.log( + "null-proto object: stays null-proto, own data prop, key present:", + Object.getPrototypeOf(nullProto) === null, + Object.prototype.hasOwnProperty.call(nullProto, "__proto__"), + Object.keys(nullProto).join(","), + ); +} + +// An own descriptor earlier in the chain shadows the inherited accessor. +{ + const ownProtoData: any = {}; + Object.defineProperty(ownProtoData, "__proto__", { + value: "before", + writable: true, + enumerable: true, + configurable: true, + }); + const parentBefore = Object.getPrototypeOf(ownProtoData); + ownProtoData.__proto__ = "after"; + console.log( + "own __proto__ data prop shadows the accessor:", + ownProtoData.__proto__, + Object.getPrototypeOf(ownProtoData) === parentBefore, + ); +} + +// A non-object, non-null RHS is silently ignored (Annex B), not thrown. +{ + const target: any = { inherited: "yes" }; + const assigned: any = {}; + assigned.__proto__ = target; + assigned.__proto__ = 7; + console.log( + "primitive RHS ignored, no throw:", + Object.getPrototypeOf(assigned) === target, + ); +} + +// Declared class instance (CLASS_DECL_PROTOTYPE_OBJECTS). +{ + class Base {} + class Derived extends Base {} + const inst = new Derived(); + console.log( + "declared class instance:", + (inst as any).__proto__ === Derived.prototype, + Object.getPrototypeOf(Derived.prototype) === Base.prototype, + ); +} + +// Plain-function constructor instance (CLASS_PROTOTYPE_OBJECTS). +{ + function Ctor(this: any) { + this.x = 1; + } + const inst: any = new (Ctor as any)(); + console.log( + "function-ctor instance:", + inst.__proto__ === (Ctor as any).prototype, + ); +} + +// Object.create(proto) synthetic object (also CLASS_PROTOTYPE_OBJECTS-style +// resolution). +{ + const base = { greet: "hi" }; + const created: any = Object.create(base); + console.log( + "Object.create(proto) synthetic object:", + created.__proto__ === base, + ); +} + +// Primitives — auto-boxed to their wrapper's prototype on read. +console.log("number primitive:", (5 as any).__proto__ === Number.prototype); +console.log( + "string primitive:", + ("s" as any).__proto__ === String.prototype, +); + +// --- Object-literal `__proto__` stays the special non-computed form ------ +{ + const litProto = { fromLiteral: true }; + const lit: any = { __proto__: litProto, y: 2 }; + console.log( + "literal __proto__ sets prototype, not an own key:", + Object.getPrototypeOf(lit) === litProto, + !Object.prototype.hasOwnProperty.call(lit, "__proto__"), + lit.y, + ); +} + +// A COMPUTED key that evaluates to "__proto__" is an ordinary own property — +// the special form only applies to the non-computed `__proto__: value` shape. +{ + const key = "__proto__"; + const computed: any = { [key]: 99 }; + console.log( + "computed __proto__ key is an ordinary own data property:", + Object.getPrototypeOf(computed) === Object.prototype, + Object.prototype.hasOwnProperty.call(computed, "__proto__"), + computed.__proto__, + ); +}