From 5ed85d8bbeb8b77bf0569b2ccc6a3ab6ac5b0cba Mon Sep 17 00:00:00 2001 From: Krzysztof Modras Date: Tue, 10 Nov 2026 11:24:01 +0100 Subject: [PATCH] Reject prototype cycles across scriptlet proxies --- proxy.js | 50 ++++++++++++++++++-------------------------------- test.js | 16 ++++++++++++++++ ubo.js | 54 ++++++++++++++++++++---------------------------------- 3 files changed, 54 insertions(+), 66 deletions(-) diff --git a/proxy.js b/proxy.js index c0cdf68..cd9b5ee 100644 --- a/proxy.js +++ b/proxy.js @@ -3,46 +3,32 @@ * the proxy or its target before changing the target's prototype. */ export function createProxy(NativeProxy) { - const reflectApply = Reflect.apply; - const reflectGetPrototypeOf = Reflect.getPrototypeOf; - const reflectSetPrototypeOf = Reflect.setPrototypeOf; + const { getPrototypeOf, setPrototypeOf } = Reflect; const objectCreate = Object.create; - const TypeErrorCtor = TypeError; - const proxies = new WeakSet(); - const { add, has } = WeakSet.prototype; - // Stop at other proxies created by this wrapper. Unrecognized proxies are - // traversed, which can invoke page-owned getPrototypeOf traps unlike native checks. - const isCyclic = (proxy, target, proto) => { - const stops = (p) => p === null || p === proxy || p === target || reflectApply(has, proxies, [p]); + // 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. + const reaches = (proto, proxy, target) => { let slow = proto; let fast = proto; - // Classic "tortoise and hare" cycle check - while (!stops(fast)) { - fast = reflectGetPrototypeOf(fast); - if (stops(fast)) break; - fast = reflectGetPrototypeOf(fast); - slow = reflectGetPrototypeOf(slow); - if (slow === fast) return false; + for (let i = 1; fast !== null; i++) { + if (fast === proxy || fast === target) return true; + fast = getPrototypeOf(fast); + if (i % 2 === 0) { + slow = getPrototypeOf(slow); + if (slow === fast) return false; + } } - return fast === proxy || fast === target; + return false; }; return function Proxy(target, handler) { - if (new.target === undefined) { - throw new TypeErrorCtor("Constructor Proxy requires 'new'"); - } - let proxy; - // Inherit from the handler rather than copy it, so traps defined on a prototype still apply - if (handler instanceof Object && handler.setPrototypeOf === undefined) { - // Returning false makes Object.setPrototypeOf throw while Reflect.setPrototypeOf - // returns false. The error message differs from native cycle rejection. - const setPrototypeOf = (target, proto) => - !(proto !== null && isCyclic(proxy, target, proto)) && reflectSetPrototypeOf(target, proto); - handler = objectCreate(handler, { setPrototypeOf: { value: setPrototypeOf } }); - } - proxy = new NativeProxy(target, handler); - reflectApply(add, proxies, [proxy]); + // Inherit from the handler rather than copy it, so traps added to it later still apply. + 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) }, + })); return proxy; }; } diff --git a/test.js b/test.js index 726ff1f..b91a519 100644 --- a/test.js +++ b/test.js @@ -116,6 +116,22 @@ suite("proxied functions", () => { }); } + test("rejects a cycle spanning two proxied functions like native functions", () => { + const probeBoth = (a, b) => ` + (() => { + const a = ${a}; + const b = ${b}; + Object.setPrototypeOf(a, Object.create(b)); + let error; + try { Object.setPrototypeOf(b, Object.create(a)); } catch (e) { error = e; } + return { error: error?.name, reflect: Reflect.setPrototypeOf(b, Object.create(a)), call: typeof b.call }; + })() + `; + const expected = vm.runInContext(probeBoth("function a() {}", "function b() {}"), vm.createContext({})); + const page = createPage("prevent-setTimeout.js", "x"); + assert.deepStrictEqual({ ...vm.runInContext(probeBoth("setTimeout", "Function.prototype.toString"), page) }, { ...expected }); + }); + test("still sets a non-cyclic prototype", () => { const page = createPage("prevent-setTimeout.js", "x"); const set = "const proto = Object.create(Function.prototype); Object.setPrototypeOf(setTimeout, proto) === setTimeout && Object.getPrototypeOf(setTimeout) === proto"; diff --git a/ubo.js b/ubo.js index 916e4e7..39a0653 100644 --- a/ubo.js +++ b/ubo.js @@ -810,46 +810,32 @@ 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 reflectApply = Reflect.apply; - const reflectGetPrototypeOf = Reflect.getPrototypeOf; - const reflectSetPrototypeOf = Reflect.setPrototypeOf; + const { getPrototypeOf, setPrototypeOf } = Reflect; const objectCreate = Object.create; - const TypeErrorCtor = TypeError; - const proxies = new WeakSet(); - const { add, has } = WeakSet.prototype; - - // Stop at other proxies created by this wrapper. Unrecognized proxies are - // traversed, which can invoke page-owned getPrototypeOf traps unlike native checks. - const isCyclic = (proxy, target, proto) => { - const stops = (p) => p === null || p === proxy || p === target || reflectApply(has, proxies, [p]); + + // 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. + const reaches = (proto, proxy, target) => { let slow = proto; let fast = proto; - // Classic "tortoise and hare" cycle check - while (!stops(fast)) { - fast = reflectGetPrototypeOf(fast); - if (stops(fast)) break; - fast = reflectGetPrototypeOf(fast); - slow = reflectGetPrototypeOf(slow); - if (slow === fast) return false; - } - return fast === proxy || fast === target; + for (let i = 1; fast !== null; i++) { + if (fast === proxy || fast === target) return true; + fast = getPrototypeOf(fast); + if (i % 2 === 0) { + slow = getPrototypeOf(slow); + if (slow === fast) return false; + } + } + return false; }; return function Proxy(target, handler) { - if (new.target === undefined) { - throw new TypeErrorCtor("Constructor Proxy requires 'new'"); - } - let proxy; - // Inherit from the handler rather than copy it, so traps defined on a prototype still apply - if (handler instanceof Object && handler.setPrototypeOf === undefined) { - // Returning false makes Object.setPrototypeOf throw while Reflect.setPrototypeOf - // returns false. The error message differs from native cycle rejection. - const setPrototypeOf = (target, proto) => - !(proto !== null && isCyclic(proxy, target, proto)) && reflectSetPrototypeOf(target, proto); - handler = objectCreate(handler, { setPrototypeOf: { value: setPrototypeOf } }); - } - proxy = new NativeProxy(target, handler); - reflectApply(add, proxies, [proxy]); + // Inherit from the handler rather than copy it, so traps added to it later still apply. + 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) }, + })); return proxy; }; })(globalThis.Proxy);