From 1548abeb73da48fe256f5733c5d956ce556b393b Mon Sep 17 00:00:00 2001 From: Krzysztof Modras Date: Thu, 12 Nov 2026 12:44:37 +0100 Subject: [PATCH 1/2] Match native Proxy for handler setPrototypeOf traps --- proxy.js | 16 +++++++--------- test.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ ubo.js | 16 +++++++--------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/proxy.js b/proxy.js index adcf180..17c7730 100644 --- a/proxy.js +++ b/proxy.js @@ -24,19 +24,17 @@ export function createProxy(NativeProxy) { }; return function Proxy(target, handler) { - // Compose with the handler's own setPrototypeOf trap, if any: read it once, as an own - // property only, so page code cannot inject one through Object.prototype. It runs after - // the cycle check below, so it only ever sees non-cyclic prototypes. - const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; - - // Inherit from the handler rather than copy it, so other traps added to it later still apply. - // A setPrototypeOf trap added to the handler after construction stays shadowed by the composed one. + // Inherit from the handler rather than copy it, so traps added to it later still apply. const proxy = new NativeProxy(target, objectCreate(handler, { - setPrototypeOf: { value(target, proto) { + setPrototypeOf: { value: (target, proto) => { // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf // return false, as both do for a native function. if (reaches(proto, proxy, target)) return false; - return trap === undefined ? setPrototypeOf(target, proto) : apply(trap, this, [target, proto]); + + // The handler's trap only ever sees non-cyclic prototypes. Own property only, + // so page code cannot inject a trap through Object.prototype. + const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; + return trap == null ? setPrototypeOf(target, proto) : apply(trap, handler, [target, proto]); } }, })); return proxy; diff --git a/test.js b/test.js index 52a1360..1f0b739 100644 --- a/test.js +++ b/test.js @@ -172,4 +172,53 @@ suite("createProxy", () => { assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(proxy)), false); assert.deepStrictEqual(calls, [proto, "reject"]); }); + + test("calls the handler's trap with the handler as this, like a native Proxy", () => { + const Proxy = createProxy(globalThis.Proxy); + let self; + const handler = { + setPrototypeOf() { + self = this; + return true; + }, + }; + Reflect.setPrototypeOf(new Proxy(function fn() {}, handler), {}); + assert.strictEqual(self, handler); + }); + + test("treats a null trap as absent, like a native Proxy", () => { + const Proxy = createProxy(globalThis.Proxy); + const target = function fn() {}; + const proto = Object.create(Function.prototype); + assert.strictEqual(Reflect.setPrototypeOf(new Proxy(target, { setPrototypeOf: null }), proto), true); + assert.strictEqual(Object.getPrototypeOf(target), proto); + }); + + test("applies a trap added to the handler after construction, like a native Proxy", () => { + const Proxy = createProxy(globalThis.Proxy); + const target = function fn() {}; + const handler = {}; + const proxy = new Proxy(target, handler); + handler.setPrototypeOf = () => false; + assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(Function.prototype)), false); + assert.strictEqual(Object.getPrototypeOf(target), Function.prototype); + + // The cycle check still comes first. + handler.setPrototypeOf = () => true; + assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(proxy)), false); + }); + + test("ignores a trap injected through Object.prototype", () => { + const Proxy = createProxy(globalThis.Proxy); + const target = function fn() {}; + const proxy = new Proxy(target, {}); + const proto = Object.create(Function.prototype); + Object.prototype.setPrototypeOf = () => false; + try { + assert.strictEqual(Reflect.setPrototypeOf(proxy, proto), true); + } finally { + delete Object.prototype.setPrototypeOf; + } + assert.strictEqual(Object.getPrototypeOf(target), proto); + }); }); diff --git a/ubo.js b/ubo.js index 186705a..0676f84 100644 --- a/ubo.js +++ b/ubo.js @@ -831,19 +831,17 @@ const Proxy = (function createProxy(NativeProxy) { }; return function Proxy(target, handler) { - // Compose with the handler's own setPrototypeOf trap, if any: read it once, as an own - // property only, so page code cannot inject one through Object.prototype. It runs after - // the cycle check below, so it only ever sees non-cyclic prototypes. - const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; - - // Inherit from the handler rather than copy it, so other traps added to it later still apply. - // A setPrototypeOf trap added to the handler after construction stays shadowed by the composed one. + // Inherit from the handler rather than copy it, so traps added to it later still apply. const proxy = new NativeProxy(target, objectCreate(handler, { - setPrototypeOf: { value(target, proto) { + setPrototypeOf: { value: (target, proto) => { // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf // return false, as both do for a native function. if (reaches(proto, proxy, target)) return false; - return trap === undefined ? setPrototypeOf(target, proto) : apply(trap, this, [target, proto]); + + // The handler's trap only ever sees non-cyclic prototypes. Own property only, + // so page code cannot inject a trap through Object.prototype. + const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; + return trap == null ? setPrototypeOf(target, proto) : apply(trap, handler, [target, proto]); } }, })); return proxy; From 7a6c63727c2af72f2bc0e03655aebe62fd52a73a Mon Sep 17 00:00:00 2001 From: Krzysztof Modras Date: Thu, 12 Nov 2026 13:01:19 +0100 Subject: [PATCH 2/2] Keep this consistent across handler traps --- proxy.js | 6 ++++-- test.js | 20 +++++++++++--------- ubo.js | 6 ++++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/proxy.js b/proxy.js index 17c7730..7189c41 100644 --- a/proxy.js +++ b/proxy.js @@ -26,7 +26,7 @@ export function createProxy(NativeProxy) { return function Proxy(target, handler) { // Inherit from the handler rather than copy it, so traps added to it later still apply. const proxy = new NativeProxy(target, objectCreate(handler, { - setPrototypeOf: { value: (target, proto) => { + setPrototypeOf: { value(target, proto) { // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf // return false, as both do for a native function. if (reaches(proto, proxy, target)) return false; @@ -34,7 +34,9 @@ export function createProxy(NativeProxy) { // The handler's trap only ever sees non-cyclic prototypes. Own property only, // so page code cannot inject a trap through Object.prototype. const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; - return trap == null ? setPrototypeOf(target, proto) : apply(trap, handler, [target, proto]); + + // Same `this` as the handler's other traps get, so state they share through it lines up. + return trap == null ? setPrototypeOf(target, proto) : apply(trap, this, [target, proto]); } }, })); return proxy; diff --git a/test.js b/test.js index 1f0b739..38525c6 100644 --- a/test.js +++ b/test.js @@ -173,17 +173,19 @@ suite("createProxy", () => { assert.deepStrictEqual(calls, [proto, "reject"]); }); - test("calls the handler's trap with the handler as this, like a native Proxy", () => { + test("shares this between the handler's traps, like a native Proxy", () => { const Proxy = createProxy(globalThis.Proxy); - let self; - const handler = { - setPrototypeOf() { - self = this; - return true; + const proxy = new Proxy(function fn() {}, { + apply() { + this.locked = true; + }, + setPrototypeOf(t, proto) { + return this.locked ? false : Reflect.setPrototypeOf(t, proto); }, - }; - Reflect.setPrototypeOf(new Proxy(function fn() {}, handler), {}); - assert.strictEqual(self, handler); + }); + assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(Function.prototype)), true); + proxy(); + assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(Function.prototype)), false); }); test("treats a null trap as absent, like a native Proxy", () => { diff --git a/ubo.js b/ubo.js index 0676f84..e0112bf 100644 --- a/ubo.js +++ b/ubo.js @@ -833,7 +833,7 @@ const Proxy = (function createProxy(NativeProxy) { return function Proxy(target, handler) { // Inherit from the handler rather than copy it, so traps added to it later still apply. const proxy = new NativeProxy(target, objectCreate(handler, { - setPrototypeOf: { value: (target, proto) => { + setPrototypeOf: { value(target, proto) { // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf // return false, as both do for a native function. if (reaches(proto, proxy, target)) return false; @@ -841,7 +841,9 @@ const Proxy = (function createProxy(NativeProxy) { // The handler's trap only ever sees non-cyclic prototypes. Own property only, // so page code cannot inject a trap through Object.prototype. const trap = apply(hasOwnProperty, handler, ['setPrototypeOf']) ? handler.setPrototypeOf : undefined; - return trap == null ? setPrototypeOf(target, proto) : apply(trap, handler, [target, proto]); + + // Same `this` as the handler's other traps get, so state they share through it lines up. + return trap == null ? setPrototypeOf(target, proto) : apply(trap, this, [target, proto]); } }, })); return proxy;