Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
};
Expand Down
35 changes: 35 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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"]);
});
});
20 changes: 15 additions & 5 deletions ubo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
};
Expand Down