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
4 changes: 4 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions proxy.js
Original file line number Diff line number Diff line change
@@ -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;
};
}
112 changes: 112 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
}
});
40 changes: 40 additions & 0 deletions ubo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading