diff --git a/build.ts b/build.ts index fbb87b7..faad594 100644 --- a/build.ts +++ b/build.ts @@ -1,3 +1,5 @@ +import { createProxy } from './proxy.js'; + const tagName = Deno.args.includes('--tagName') ? Deno.args[Deno.args.findIndex((o) => o === '--tagName') + 1] : false; @@ -52,7 +54,9 @@ fn: '${scriptlet.fn.name}', .join('\n')} // The calls come last, so the class dependencies, which are not hoisted, are declared by then. +// Proxy shadows the global for every scriptlet in run(), see proxy.js. export function run(scriptletGlobals, calls) { +const Proxy = (${createProxy})(globalThis.Proxy); ${[...functions.values()].join('\n')} const table = { ${entries.map((scriptlet) => scriptlet.fn.name).join(', ')} }; for (const [name, ...args] of calls) { diff --git a/proxy.js b/proxy.js new file mode 100644 index 0000000..97627d5 --- /dev/null +++ b/proxy.js @@ -0,0 +1,43 @@ +/** + * Returns a Proxy constructor for scriptlets. Their handlers do not define + * setPrototypeOf; the page-provided prototype chains are untrusted. + */ +export function createProxy(NativeProxy) { + const reflectGetPrototypeOf = Reflect.getPrototypeOf; + const reflectSetPrototypeOf = Reflect.setPrototypeOf; + + // Walk through other scriptlet proxies to catch cycles spanning several hooks. + // This can invoke page-owned getPrototypeOf traps, unlike native cycle checks. + const isCyclic = (proxy, target, proto) => { + const stops = (p) => p === null || p === proxy || p === 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; + }; + + return function Proxy(target, handler) { + // Copy only the scriptlet's own traps; never inherit traps from the page. + const proxy = new NativeProxy(target, { + __proto__: null, + ...handler, + setPrototypeOf(target, proto) { + // Do not inspect the target: it may itself be a page-owned proxy. + if (isCyclic(proxy, target, proto)) { + // Object.setPrototypeOf throws; Reflect.setPrototypeOf returns false. + // The engine's error text still differs from native cycle rejection. + return false; + } + return reflectSetPrototypeOf(target, proto); + }, + }); + return proxy; + }; +} diff --git a/test.js b/test.js index a0be436..b7c1d92 100644 --- a/test.js +++ b/test.js @@ -84,3 +84,115 @@ suite("run()", () => { assert.strictEqual(keys.length, 0); }); }); + +suite("proxied functions", () => { + // A fresh realm is the page; returns it with the scriptlet applied + function createPage(name, ...args) { + const page = vm.createContext({ EventTarget: class {}, Request: class {} }); + vm.runInContext("self = globalThis; nativeSetTimeout = setTimeout = function setTimeout() {};", page); + vm.runInContext(`(${runSource})(...${JSON.stringify([{}, [[scriptlets[name].fn, ...args]]])});`, page); + return page; + } + + // Akamai Bot Manager's proxy probe + const probe = (fn) => ` + (() => { + const fn = ${fn}; + let error; + try { Object.setPrototypeOf(fn, Object.create(fn)); } catch (e) { error = e; } + return { error: error?.name, reflect: Reflect.setPrototypeOf(fn, Object.create(fn)), call: typeof fn.call }; + })() + `; + const native = vm.runInContext(probe("function native() {}"), vm.createContext({})); + + for (const [name, fn] of [ + ["prevent-setTimeout.js", "Function.prototype.toString"], // proxied by proxyApplyFn() + ["adjust-setTimeout.js", "setTimeout"], // proxied by the scriptlet itself + ]) { + test(`${name}: rejects a cyclic prototype on ${fn} like a native function`, () => { + const page = createPage(name, "x"); + assert(vm.runInContext("setTimeout !== nativeSetTimeout", page), `${name} did not run`); + assert.deepStrictEqual({ ...vm.runInContext(probe(fn), page) }, { ...native }); + }); + } + + 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"; + assert(vm.runInContext(set, page)); + }); + + test("can change prototypes when the page supplied a proxy target", () => { + const page = vm.createContext({ EventTarget: class {}, Request: class {} }); + vm.runInContext(` + self = globalThis; + nativeSetTimeout = function setTimeout() {}; + pageSetTimeout = setTimeout = new Proxy(nativeSetTimeout, { + getPrototypeOf() { throw new Error("page target trap ran"); }, + }); + `, page); + const calls = [[scriptlets["prevent-setTimeout.js"].fn, "x"]]; + vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page); + assert(vm.runInContext("setTimeout !== pageSetTimeout", page)); + assert(vm.runInContext(` + const proto = Object.create(Function.prototype); + Object.setPrototypeOf(setTimeout, proto) === setTimeout && + Object.getPrototypeOf(nativeSetTimeout) === proto; + `, page)); + assert.deepStrictEqual({ ...vm.runInContext(probe("setTimeout"), page) }, { ...native }); + }); + + for (const timing of ["before", "after"]) { + test(`ignores page-defined traps added ${timing} scriptlet injection`, () => { + const page = vm.createContext({ EventTarget: class {}, Request: class {} }); + vm.runInContext(` + self = globalThis; + nativeSetTimeout = setTimeout = function setTimeout(callback) { callback(); return 42; }; + `, page); + const pollute = ` + for (const trap of ["get", "getPrototypeOf", "setPrototypeOf", "apply"]) { + Object.defineProperty(Object.prototype, trap, { + __proto__: null, + value() { throw new Error("page trap ran: " + trap); }, + }); + } + `; + if (timing === "before") vm.runInContext(pollute, page); + const calls = [[scriptlets["prevent-setTimeout.js"].fn, "blockedProbe"]]; + vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page); + if (timing === "after") vm.runInContext(pollute, page); + assert(vm.runInContext("setTimeout !== nativeSetTimeout", page)); + assert.deepStrictEqual({ ...vm.runInContext(probe("setTimeout"), page) }, { ...native }); + const result = vm.runInContext(` + const original = Object.getPrototypeOf(setTimeout); + Object.setPrototypeOf(setTimeout, Object.create(Function.prototype.toString)); + const cycle = Reflect.setPrototypeOf(Function.prototype.toString, Object.create(setTimeout)); + Object.setPrototypeOf(setTimeout, original); + let blocked = false, allowed = false; + const blockedResult = setTimeout(function blockedProbe() { blocked = true; }); + const allowedResult = setTimeout(function allowedProbe() { allowed = true; }); + ({ cycle, name: setTimeout.name, blocked, allowed, blockedResult, allowedResult }); + `, page); + assert.deepStrictEqual({ ...result }, { + cycle: false, name: "setTimeout", blocked: false, allowed: true, + blockedResult: 42, allowedResult: 42, + }); + }); + } +}); diff --git a/ubo.js b/ubo.js index fd9ad99..8e65962 100644 --- a/ubo.js +++ b/ubo.js @@ -807,7 +807,47 @@ fn: 'trustedOverrideElementMethod', // The calls come last, so the class dependencies, which are not hoisted, are declared by then. +// Proxy shadows the global for every scriptlet in run(), see proxy.js. export function run(scriptletGlobals, calls) { +const Proxy = (function createProxy(NativeProxy) { + const reflectGetPrototypeOf = Reflect.getPrototypeOf; + const reflectSetPrototypeOf = Reflect.setPrototypeOf; + + // Walk through other scriptlet proxies to catch cycles spanning several hooks. + // This can invoke page-owned getPrototypeOf traps, unlike native cycle checks. + const isCyclic = (proxy, target, proto) => { + const stops = (p) => p === null || p === proxy || p === 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; + }; + + return function Proxy(target, handler) { + // Copy only the scriptlet's own traps; never inherit traps from the page. + const proxy = new NativeProxy(target, { + __proto__: null, + ...handler, + setPrototypeOf(target, proto) { + // Do not inspect the target: it may itself be a page-owned proxy. + if (isCyclic(proxy, target, proto)) { + // Object.setPrototypeOf throws; Reflect.setPrototypeOf returns false. + // The engine's error text still differs from native cycle rejection. + return false; + } + return reflectSetPrototypeOf(target, proto); + }, + }); + return proxy; + }; +})(globalThis.Proxy); function safeSelf() { if ( safeSelf.safe ) { return safeSelf.safe;