diff --git a/proxy.js b/proxy.js index cd9b5ee..adcf180 100644 --- a/proxy.js +++ b/proxy.js @@ -3,8 +3,9 @@ * the proxy or its target before changing the target's prototype. */ export function createProxy(NativeProxy) { - const { getPrototypeOf, setPrototypeOf } = Reflect; + const { apply, getPrototypeOf, setPrototypeOf } = Reflect; const objectCreate = Object.create; + const hasOwnProperty = Object.prototype.hasOwnProperty; // Proxies are walked like any other object, so a cycle spanning several scriptlet // proxies is found. The tortoise ends walks over chains that are already cyclic. @@ -23,11 +24,20 @@ 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. + // 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. const proxy = new NativeProxy(target, objectCreate(handler, { - // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf - // return false, as both do for a native function. - setPrototypeOf: { value: (target, proto) => !reaches(proto, proxy, target) && setPrototypeOf(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]); + } }, })); return proxy; }; diff --git a/test.js b/test.js index b91a519..52a1360 100644 --- a/test.js +++ b/test.js @@ -2,6 +2,7 @@ import { test, suite } from "node:test"; import assert from "node:assert"; import vm from "node:vm"; import scriptlets, { run } from "./index.js"; +import { createProxy } from "./proxy.js"; const runSource = run.toString(); @@ -138,3 +139,37 @@ suite("proxied functions", () => { assert(vm.runInContext(set, page)); }); }); + +suite("createProxy", () => { + test("preserves a handler's own setPrototypeOf trap", () => { + const Proxy = createProxy(globalThis.Proxy); + const target = function fn() {}; + const calls = []; + const proxy = new Proxy(target, { + setPrototypeOf(t, proto) { + calls.push(proto); + return Reflect.setPrototypeOf(t, proto); + }, + }); + + // Non-cyclic: the handler's trap runs and its result is honored. + const proto = Object.create(Function.prototype); + assert.strictEqual(Reflect.setPrototypeOf(proxy, proto), true); + assert.deepStrictEqual(calls, [proto]); + assert.strictEqual(Object.getPrototypeOf(target), proto); + + // A falsish result from the handler's trap is honored, too. + const rejecting = new Proxy(target, { + setPrototypeOf() { + calls.push("reject"); + return false; + }, + }); + assert.strictEqual(Reflect.setPrototypeOf(rejecting, Object.create(Function.prototype)), false); + + // Cyclic: rejected before the handler's trap runs, like a native function. + assert.throws(() => Object.setPrototypeOf(proxy, Object.create(proxy)), TypeError); + assert.strictEqual(Reflect.setPrototypeOf(proxy, Object.create(proxy)), false); + assert.deepStrictEqual(calls, [proto, "reject"]); + }); +}); diff --git a/ubo.js b/ubo.js index 39a0653..186705a 100644 --- a/ubo.js +++ b/ubo.js @@ -810,8 +810,9 @@ fn: 'trustedOverrideElementMethod', // Proxy shadows the global for every scriptlet in run(), see proxy.js. export function run(scriptletGlobals, calls) { const Proxy = (function createProxy(NativeProxy) { - const { getPrototypeOf, setPrototypeOf } = Reflect; + const { apply, getPrototypeOf, setPrototypeOf } = Reflect; const objectCreate = Object.create; + const hasOwnProperty = Object.prototype.hasOwnProperty; // Proxies are walked like any other object, so a cycle spanning several scriptlet // proxies is found. The tortoise ends walks over chains that are already cyclic. @@ -830,11 +831,20 @@ 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. + // 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. const proxy = new NativeProxy(target, objectCreate(handler, { - // Returning false makes Object.setPrototypeOf throw and Reflect.setPrototypeOf - // return false, as both do for a native function. - setPrototypeOf: { value: (target, proto) => !reaches(proto, proxy, target) && setPrototypeOf(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]); + } }, })); return proxy; };