Skip to content

Commit 47e797a

Browse files
committed
Reject cyclic prototypes on scriptlet proxies
A Proxy without a setPrototypeOf trap passes the call to its target, whose cycle check stops at the proxy. Akamai Bot Manager's probe Object.setPrototypeOf(fn, Object.create(fn)) therefore succeeds on the proxied Function.prototype.toString and leaves a prototype cycle, so every later toString/.call overflows the stack (seen on kroger.com, fredmeyer.com). run() now declares a Proxy that shadows the global for all scriptlets. It adds a setPrototypeOf trap that rejects a cycle.
1 parent 9f09b9f commit 47e797a

4 files changed

Lines changed: 158 additions & 0 deletions

File tree

‎build.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createProxy } from './proxy.js';
2+
13
const tagName = Deno.args.includes('--tagName')
24
? Deno.args[Deno.args.findIndex((o) => o === '--tagName') + 1]
35
: false;
@@ -52,7 +54,9 @@ fn: '${scriptlet.fn.name}',
5254
.join('\n')}
5355
5456
// The calls come last, so the class dependencies, which are not hoisted, are declared by then.
57+
// Proxy shadows the global for every scriptlet in run(), see proxy.js.
5558
export function run(scriptletGlobals, calls) {
59+
const Proxy = (${createProxy})(globalThis.Proxy);
5660
${[...functions.values()].join('\n')}
5761
const table = { ${entries.map((scriptlet) => scriptlet.fn.name).join(', ')} };
5862
for (const [name, ...args] of calls) {

‎proxy.js‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Returns a drop-in replacement for the Proxy constructor whose proxies reject a cyclic
3+
* prototype the way a native function does.
4+
*/
5+
export function createProxy(NativeProxy) {
6+
const reflectGetPrototypeOf = Reflect.getPrototypeOf;
7+
const reflectSetPrototypeOf = Reflect.setPrototypeOf;
8+
const objectCreate = Object.create;
9+
const TypeErrorCtor = TypeError;
10+
11+
// Native error message
12+
let cyclicMessage = 'Cyclic __proto__ value';
13+
try {
14+
const o = {};
15+
Object.setPrototypeOf(o, Object.create(o));
16+
} catch (e) {
17+
cyclicMessage = e.message;
18+
}
19+
20+
const isCyclic = (proto) => {
21+
let slow = proto;
22+
let fast = proto;
23+
// Classic "tortoise and hare" cycle check
24+
while (fast !== null) {
25+
fast = reflectGetPrototypeOf(fast);
26+
if (fast === null) return false;
27+
fast = reflectGetPrototypeOf(fast);
28+
slow = reflectGetPrototypeOf(slow);
29+
if (fast === slow) return true;
30+
}
31+
return false;
32+
};
33+
34+
// Set first, then verify the resulting chain and undo a cycle.
35+
// A check before the set misses cycles through other proxies stacked on the same target
36+
const setPrototypeOf = (target, proto) => {
37+
const before = reflectGetPrototypeOf(target);
38+
if (reflectSetPrototypeOf(target, proto) === false) return false;
39+
let cyclic = true;
40+
try {
41+
cyclic = proto !== null && isCyclic(proto);
42+
} finally {
43+
if (cyclic) reflectSetPrototypeOf(target, before);
44+
}
45+
if (cyclic) throw new TypeErrorCtor(cyclicMessage);
46+
return true;
47+
};
48+
49+
return function Proxy(target, handler) {
50+
if (new.target === undefined) {
51+
throw new TypeErrorCtor("Constructor Proxy requires 'new'");
52+
}
53+
// Inherit from the handler rather than copy it, so traps defined on a prototype still apply
54+
if (handler instanceof Object && handler.setPrototypeOf === undefined) {
55+
handler = objectCreate(handler, { setPrototypeOf: { value: setPrototypeOf } });
56+
}
57+
return new NativeProxy(target, handler);
58+
};
59+
}

‎test.js‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,42 @@ suite("run()", () => {
8484
assert.strictEqual(keys.length, 0);
8585
});
8686
});
87+
88+
suite("proxied functions", () => {
89+
// A fresh realm is the page; returns it with the scriptlet applied
90+
function createPage(name, ...args) {
91+
const page = vm.createContext({ EventTarget: class {}, Request: class {} });
92+
vm.runInContext("self = globalThis; nativeSetTimeout = setTimeout = function setTimeout() {};", page);
93+
vm.runInContext(`(${runSource})(...${JSON.stringify([{}, [[scriptlets[name].fn, ...args]]])});`, page);
94+
return page;
95+
}
96+
97+
// Akamai Bot Manager's proxy probe: a native function rejects the cyclic prototype, a proxy
98+
// forwarding [[SetPrototypeOf]] accepts it and every later lookup on the function overflows
99+
const probe = (fn) => `
100+
(() => {
101+
const fn = ${fn};
102+
let error;
103+
try { Object.setPrototypeOf(fn, Object.create(fn)); } catch (e) { error = e; }
104+
return { name: error?.name, message: error?.message, call: typeof fn.call };
105+
})()
106+
`;
107+
const native = vm.runInContext(probe("function native() {}"), vm.createContext({}));
108+
109+
for (const [name, fn] of [
110+
["prevent-setTimeout.js", "Function.prototype.toString"], // proxied by proxyApplyFn()
111+
["adjust-setTimeout.js", "setTimeout"], // proxied by the scriptlet itself
112+
]) {
113+
test(`${name}: rejects a cyclic prototype on ${fn} like a native function`, () => {
114+
const page = createPage(name, "x");
115+
assert(vm.runInContext("setTimeout !== nativeSetTimeout", page), `${name} did not run`);
116+
assert.deepStrictEqual({ ...vm.runInContext(probe(fn), page) }, { ...native });
117+
});
118+
}
119+
120+
test("still sets a non-cyclic prototype", () => {
121+
const page = createPage("prevent-setTimeout.js", "x");
122+
const set = "const proto = Object.create(Function.prototype); Object.setPrototypeOf(setTimeout, proto) === setTimeout && Object.getPrototypeOf(setTimeout) === proto";
123+
assert(vm.runInContext(set, page));
124+
});
125+
});

‎ubo.js‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,63 @@ fn: 'trustedOverrideElementMethod',
807807

808808

809809
// The calls come last, so the class dependencies, which are not hoisted, are declared by then.
810+
// Proxy shadows the global for every scriptlet in run(), see proxy.js.
810811
export function run(scriptletGlobals, calls) {
812+
const Proxy = (function createProxy(NativeProxy) {
813+
const reflectGetPrototypeOf = Reflect.getPrototypeOf;
814+
const reflectSetPrototypeOf = Reflect.setPrototypeOf;
815+
const objectCreate = Object.create;
816+
const TypeErrorCtor = TypeError;
817+
818+
// Native error message
819+
let cyclicMessage = 'Cyclic __proto__ value';
820+
try {
821+
const o = {};
822+
Object.setPrototypeOf(o, Object.create(o));
823+
} catch (e) {
824+
cyclicMessage = e.message;
825+
}
826+
827+
const isCyclic = (proto) => {
828+
let slow = proto;
829+
let fast = proto;
830+
// Classic "tortoise and hare" cycle check
831+
while (fast !== null) {
832+
fast = reflectGetPrototypeOf(fast);
833+
if (fast === null) return false;
834+
fast = reflectGetPrototypeOf(fast);
835+
slow = reflectGetPrototypeOf(slow);
836+
if (fast === slow) return true;
837+
}
838+
return false;
839+
};
840+
841+
// Set first, then verify the resulting chain and undo a cycle.
842+
// A check before the set misses cycles through other proxies stacked on the same target
843+
const setPrototypeOf = (target, proto) => {
844+
const before = reflectGetPrototypeOf(target);
845+
if (reflectSetPrototypeOf(target, proto) === false) return false;
846+
let cyclic = true;
847+
try {
848+
cyclic = proto !== null && isCyclic(proto);
849+
} finally {
850+
if (cyclic) reflectSetPrototypeOf(target, before);
851+
}
852+
if (cyclic) throw new TypeErrorCtor(cyclicMessage);
853+
return true;
854+
};
855+
856+
return function Proxy(target, handler) {
857+
if (new.target === undefined) {
858+
throw new TypeErrorCtor("Constructor Proxy requires 'new'");
859+
}
860+
// Inherit from the handler rather than copy it, so traps defined on a prototype still apply
861+
if (handler instanceof Object && handler.setPrototypeOf === undefined) {
862+
handler = objectCreate(handler, { setPrototypeOf: { value: setPrototypeOf } });
863+
}
864+
return new NativeProxy(target, handler);
865+
};
866+
})(globalThis.Proxy);
811867
function safeSelf() {
812868
if ( safeSelf.safe ) {
813869
return safeSelf.safe;

0 commit comments

Comments
 (0)