Skip to content

Commit 58bae9d

Browse files
Share one safeSelf() cache between the scriptlets in a frame. (#63)
Context: Each scriptlet has its own copy of safeSelf(). It saves the functions that it finds when it first runs, and those were often already the wrappers that earlier scriptlets had installed, not the browser's own functions. So each hook called all earlier hooks again. With six hooks on JSON.stringify (as on youtube.com), one JSON.stringify call from the page became 2^6=64 native calls. The extension gives each scriptlet its own copy of scriptletGlobals, so the first scriptlet in a frame (and world) now keeps the cache on globalThis, and the later scriptlets use it. The property is read-only, and its key has the uBO version, so other versions do not share it. This works for all injection paths (registered scripts and executeScript), without changes in the extension. refs ghostery/ghostery-extension#3672
1 parent 44cbeb1 commit 58bae9d

3 files changed

Lines changed: 787 additions & 1 deletion

File tree

‎build.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ for (const scriptlet of scriptlets) {
1818
}
1919
}
2020

21+
// uBO runs all scriptlets of a page in one scope, so they share one safeSelf() cache. Our scriptlets are separate functions, so they share it on globalThis.
22+
// Else each hook captures the earlier hooks as natives, and calls grow as 2^n. The uBO version is in the key, so other versions do not share it.
23+
const safeSelf = index.get('safe-self.fn').fn;
24+
const shareSafeSelf = `try {
25+
const key = Symbol.for('safeSelf.${tagName}');
26+
safeSelf.safe = globalThis[key];
27+
if ( safeSelf.safe === undefined ) {
28+
Object.defineProperty(globalThis, key, { value: safeSelf() });
29+
}
30+
} catch {
31+
}
32+
`;
33+
2134
console.log(`
2235
/*******************************************************************************
2336
@@ -67,7 +80,7 @@ requiresTrust: ${scriptlet.requiresTrust || false},
6780
func: function (scriptletGlobals = {}, ...args) {
6881
${deps.map((dep) => dep.toString()).join('\n')}
6982
${scriptlet.fn.toString()};
70-
${scriptlet.fn.name}(...args);
83+
${deps.includes(safeSelf) ? shareSafeSelf : ''}${scriptlet.fn.name}(...args);
7184
},
7285
};
7386
`;

‎test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test, suite } from "node:test";
22
import assert from "node:assert";
3+
import vm from "node:vm";
34
import scriptlets from "./index.js";
45

56
test("default export is an object", () => {
@@ -26,3 +27,55 @@ suite("uBO", () => {
2627
assert.strictEqual(scriptlets["set-constant.js"], scriptlets["set.js"]);
2728
});
2829
});
30+
31+
suite("safeSelf() cache", () => {
32+
// Each hook clones the argument with safe.JSON_parse(safe.JSON_stringify(obj))
33+
const func = scriptlets["trusted-edit-inbound-object.js"].func;
34+
const HOOKS = 6;
35+
// Same code as the extension makes for each scriptlet; the first argument is scriptletGlobals
36+
const hooks = Array.from({ length: HOOKS }, (_, i) =>
37+
`(${func})(...${JSON.stringify([{}, "JSON.stringify", "0", `[?.hook${i}]+={"edited${i}":true}`])});`
38+
);
39+
40+
// A fresh realm is the page; counters wrap the native JSON methods before the scriptlets run
41+
function createPage() {
42+
const page = vm.createContext({ EventTarget: class {}, Request: class {} });
43+
vm.runInContext(
44+
`
45+
const { parse, stringify } = JSON;
46+
globalThis.calls = { parse: 0, stringify: 0 };
47+
JSON.parse = function (...args) { calls.parse += 1; return parse.apply(JSON, args); };
48+
JSON.stringify = function (...args) { calls.stringify += 1; return stringify.apply(JSON, args); };
49+
`,
50+
page
51+
);
52+
return page;
53+
}
54+
55+
test("is shared in a realm, so stacked hooks stay linear", () => {
56+
const page = createPage();
57+
for (const hook of hooks) {
58+
vm.runInContext(hook, page);
59+
}
60+
61+
const result = vm.runInContext(
62+
"calls.parse = 0; calls.stringify = 0; JSON.stringify({ hook0: true, hook5: true });",
63+
page
64+
);
65+
assert.deepStrictEqual(JSON.parse(result), { hook0: true, hook5: true, edited0: true, edited5: true });
66+
// One call from the page plus one clone per hook, not 2^n
67+
assert.deepStrictEqual({ ...page.calls }, { parse: HOOKS, stringify: HOOKS + 1 });
68+
});
69+
70+
test("is read-only and not enumerable", () => {
71+
const page = createPage();
72+
vm.runInContext(hooks.join("\n"), page);
73+
74+
const keys = vm
75+
.runInContext("Object.getOwnPropertySymbols(globalThis)", page)
76+
.filter((key) => key.description.startsWith("safeSelf."));
77+
assert.strictEqual(keys.length, 1);
78+
const { value, ...flags } = Object.getOwnPropertyDescriptor(page, keys[0]);
79+
assert.deepStrictEqual(flags, { writable: false, enumerable: false, configurable: false });
80+
});
81+
});

0 commit comments

Comments
 (0)