浏览代码

fix: guard main globals in a same-origin page context

* tentatively fixes #1400
* reimplements GM_addElement properly, #1335
+ fixes registerScriptDataFF, regressed in 7c332075
tophf 4 年之前
父节点
当前提交
5a78bdd125
共有 39 个文件被更改,包括 1292 次插入840 次删除
  1. 44 30
      .eslintrc.js
  2. 23 10
      scripts/sandbox-globals.html
  3. 45 21
      scripts/webpack.conf.js
  4. 13 7
      src/background/index.js
  5. 40 29
      src/background/utils/preinject.js
  6. 32 20
      src/common/browser.js
  7. 21 9
      src/common/safe-globals.js
  8. 4 1
      src/common/ua.js
  9. 3 0
      src/common/util.js
  10. 15 10
      src/injected/content/bridge.js
  11. 8 8
      src/injected/content/clipboard.js
  12. 82 45
      src/injected/content/index.js
  13. 60 126
      src/injected/content/inject.js
  14. 8 6
      src/injected/content/notifications.js
  15. 4 3
      src/injected/content/requests.js
  16. 37 0
      src/injected/content/safe-globals-content.js
  17. 6 5
      src/injected/content/tabs.js
  18. 74 0
      src/injected/content/util-content.js
  19. 2 3
      src/injected/index.js
  20. 17 0
      src/injected/safe-globals-injected.js
  21. 0 22
      src/injected/safe-injected-globals.js
  22. 71 0
      src/injected/util/index.js
  23. 0 58
      src/injected/utils/helpers.js
  24. 0 11
      src/injected/utils/index.js
  25. 12 20
      src/injected/web/bridge.js
  26. 43 63
      src/injected/web/gm-api.js
  27. 5 6
      src/injected/web/gm-values.js
  28. 265 259
      src/injected/web/gm-wrapper.js
  29. 23 23
      src/injected/web/index.js
  30. 3 2
      src/injected/web/notifications.js
  31. 27 26
      src/injected/web/requests.js
  32. 150 0
      src/injected/web/safe-globals-web.js
  33. 3 1
      src/injected/web/store.js
  34. 2 1
      src/injected/web/tabs.js
  35. 133 0
      src/injected/web/util-web.js
  36. 6 5
      src/popup/views/app.vue
  37. 2 2
      test/injected/gm-resource.test.js
  38. 1 1
      test/injected/helpers.test.js
  39. 8 7
      test/mock/polyfill.js

+ 44 - 30
.eslintrc.js

@@ -1,24 +1,28 @@
 const acorn = require('acorn');
-const unsafeEnvironment = [
-  'src/injected/**/*.js',
-];
-// some functions are used by `injected`
-const unsafeSharedEnvironment = [
+const FILES_INJECTED = [`src/injected/**/*.js`];
+const FILES_CONTENT = [`src/injected/content/**/*.js`];
+const FILES_WEB = [`src/injected/web/**/*.js`];
+  // some functions are used by `injected`
+const FILES_SHARED = [
   'src/common/browser.js',
   'src/common/consts.js',
   'src/common/index.js',
   'src/common/object.js',
   'src/common/util.js',
 ];
-const unsafeAndSharedEnvironment = [
-  ...unsafeEnvironment,
-  ...unsafeSharedEnvironment,
-];
-const commonGlobals = getGlobals('src/common/safe-globals.js');
-const injectedGlobals = {
-  ...commonGlobals,
-  ...getGlobals('src/injected/safe-injected-globals.js'),
+
+const GLOBALS_COMMON = getGlobals('src/common/safe-globals.js');
+const GLOBALS_INJECTED = getGlobals(`src/injected/safe-globals-injected.js`);
+const GLOBALS_CONTENT = {
+  ...getGlobals(`src/injected/content/safe-globals-content.js`),
+  ...GLOBALS_INJECTED,
+};
+const GLOBALS_WEB = {
+  ...getGlobals(`src/injected/web/safe-globals-web.js`),
+  ...GLOBALS_INJECTED,
+  IS_FIREFOX: false, // passed as a parameter to VMInitInjection in webpack.conf.js
 };
+
 module.exports = {
   root: true,
   extends: [
@@ -34,24 +38,30 @@ module.exports = {
     // `browser` is a local variable since we remove the global `chrome` and `browser` in injected*
     // to prevent exposing them to userscripts with `@inject-into content`
     files: ['*'],
-    excludedFiles: unsafeAndSharedEnvironment,
+    excludedFiles: [...FILES_INJECTED, ...FILES_SHARED],
     globals: {
       browser: false,
-      ...commonGlobals,
+      ...GLOBALS_COMMON,
     },
   }, {
-    files: unsafeSharedEnvironment,
-    globals: commonGlobals,
+    files: FILES_SHARED,
+    globals: GLOBALS_COMMON,
+  }, {
+    files: FILES_WEB,
+    globals: GLOBALS_WEB,
+  }, {
+    files: FILES_CONTENT,
+    globals: GLOBALS_CONTENT,
   }, {
-    files: unsafeEnvironment,
-    globals: injectedGlobals,
+    files: FILES_INJECTED,
+    excludedFiles: [...FILES_CONTENT, ...FILES_WEB],
+    // intersection of globals in CONTENT and WEB
+    globals: Object.keys(GLOBALS_CONTENT).reduce((res, key) => (
+      Object.assign(res, key in GLOBALS_WEB && { [key]: false })
+    ), {}),
   }, {
-    files: unsafeAndSharedEnvironment,
+    files: [...FILES_INJECTED, ...FILES_SHARED],
     rules: {
-      // Whitelisting our safe globals
-      'no-restricted-globals': ['error',
-        ...require('confusing-browser-globals').filter(x => injectedGlobals[x] == null),
-      ],
       /* Our .browserslistrc targets old browsers so the compiled code for {...objSpread} uses
          babel's polyfill that calls methods like `Object.assign` instead of our safe `assign`.
          Ideally, `eslint-plugin-compat` should be used but I couldn't make it work. */
@@ -60,13 +70,14 @@ module.exports = {
         message: 'Object spread adds a polyfill in injected* even if unused by it',
       }, {
         selector: 'OptionalCallExpression',
-        message: 'Optional call in an unsafe environment',
+        message: 'Optional call uses .call(), which may be spoofed/broken in an unsafe environment',
+        // TODO: write a Babel plugin to use safeCall for this.
       }, {
         selector: 'ArrayPattern',
-        message: 'Array destructuring in an unsafe environment',
+        message: 'Destructuring via Symbol.iterator may be spoofed/broken in an unsafe environment',
       }, {
-        selector: 'CallExpression > SpreadElement',
-        message: 'Array spreading in an unsafe environment',
+        selector: ':matches(ArrayExpression, CallExpression) > SpreadElement',
+        message: 'Spreading via Symbol.iterator may be spoofed/broken in an unsafe environment',
       }],
     },
   }, {
@@ -100,8 +111,11 @@ module.exports = {
 function getGlobals(fileName) {
   const text = require('fs').readFileSync(fileName, { encoding: 'utf8' });
   const res = {};
-  acorn.parse(text, { ecmaVersion: 2018, sourceType: 'module' }).body.forEach(body => {
-    (body.declaration || body).declarations.forEach(function processId({ id: { name, properties } }) {
+  const tree = acorn.parse(text, { ecmaVersion: 2018, sourceType: 'module' });
+  tree.body.forEach(body => {
+    const { declarations } = body.declaration || body;
+    if (!declarations) return;
+    declarations.forEach(function processId({ id: { left, properties, name = left && left.name } }) {
       if (name) {
         // const NAME = whatever
         res[name] = false;

+ 23 - 10
scripts/sandbox-globals.html

@@ -4,7 +4,7 @@
 <script>
 const el = document.getElementById('results');
 el.value = [
-  function boundMethods() {
+  function boundMethods(isHeader) {
     const keys = [];
     /* global globalThis */
     for (const k in globalThis) {
@@ -28,25 +28,38 @@ el.value = [
         }
       }
     }
-    return keys.sort().map(k => `'${k}',`).join('\n');
+    return {
+      header: "const MAYBE = vmOwnFunc; // something that can't be imitated by the page\n",
+      keys: keys,
+      value: 'MAYBE',
+    };
   },
   function unforgeables() {
     return Object.entries(Object.getOwnPropertyDescriptors(window))
     .filter(([, v]) => !v.configurable)
-    .map(([k]) => `'${k}',`)
-    .sort()
-    .join('\n');
+    .map(([k]) => k);
   },
-  function readonlys() {
+  function readonlyKeys() {
     return Object.entries(Object.getOwnPropertyDescriptors(window))
     .filter(([k, v]) => k >= 'a' && k <= 'z'
       && k !== 'webkitStorageInfo' // deprecated
       && v.get && !v.set && v.configurable)
-    .map(([k]) => `'${k}',`)
-    .sort()
-    .join('\n');
+    .map(([k]) => k);
   },
-].map(fn => `// ${fn.name}\n${fn()}\n`).join('\n');
+].map(fn => {
+  let res = fn();
+  if (Array.isArray(res)) res = { keys: res };
+  return `${res.header || ''}const ${fn.name} = {
+  __proto__: null,
+${
+  res.keys.sort()
+  .map(k => `  ${k}: ${res.value || 1},`)
+  .join('\n')
+}
+}
+`;
+}).join('\n');
+
 el.focus();
 el.select();
 </script>

+ 45 - 21
scripts/webpack.conf.js

@@ -10,7 +10,15 @@ const { ListBackgroundScriptsPlugin } = require('./manifest-helper');
 const projectConfig = require('./plaid.conf');
 const mergedConfig = shallowMerge(defaultOptions, projectConfig);
 
-const INIT_FUNC_NAME = 'VMInitInjection';
+// Avoiding collisions with globals of a content-mode userscript
+const INIT_FUNC_NAME = `Violentmonkey:${
+  Buffer.from(
+    new Uint32Array(2)
+    .map(() => Math.random() * (2 ** 32))
+    .buffer,
+  ).toString('base64')
+}`;
+const VAULT_ID = '__VAULT_ID__';
 // eslint-disable-next-line import/no-dynamic-require
 const VM_VER = require(`${defaultOptions.distDir}/manifest.json`).version;
 const WEBPACK_OPTS = {
@@ -29,6 +37,11 @@ const MIN_OPTS = {
   parallel: true,
   sourceMap: true,
   terserOptions: {
+    compress: {
+      // `terser` often inlines big one-time functions inside a small "hot" function
+      reduce_funcs: false,
+      reduce_vars: false,
+    },
     output: {
       ascii_only: true,
     },
@@ -58,7 +71,7 @@ const pickEnvs = (items) => {
   })));
 };
 
-const definitions = new webpack.DefinePlugin({
+const defsObj = {
   ...pickEnvs([
     { key: 'DEBUG', def: false },
     { key: 'VM_VER', val: VM_VER },
@@ -68,39 +81,47 @@ const definitions = new webpack.DefinePlugin({
     { key: 'SYNC_ONEDRIVE_CLIENT_SECRET' },
   ]),
   'process.env.INIT_FUNC_NAME': JSON.stringify(INIT_FUNC_NAME),
-});
+  'process.env.VAULT_ID': VAULT_ID,
+};
+const defsRe = new RegExp(`\\b(${Object.keys(defsObj).join('|').replace(/\./g, '\\.')})\\b`, 'g');
+const definitions = new webpack.DefinePlugin(defsObj);
 
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts
 const skipReinjectionHeader = `if (window['${INIT_FUNC_NAME}'] !== 1)`;
 // {entryName: path}
 const entryGlobals = {
-  common: './src/common/safe-globals.js',
-  injected: './src/injected/safe-injected-globals.js',
+  common: [
+    './src/common/safe-globals.js',
+  ],
+  'injected/content': [
+    './src/injected/safe-globals-injected.js',
+    './src/injected/content/safe-globals-content.js',
+  ],
+  'injected/web': [
+    './src/injected/safe-globals-injected.js',
+    './src/injected/web/safe-globals-web.js',
+  ],
 };
 
 /**
  * Adds a watcher for files in entryGlobals to properly recompile the project on changes.
  */
 const addWrapper = (config, name, callback) => {
-  if (!callback) { callback = name; name = ''; }
-  const globals = Object.entries(entryGlobals).filter(([key]) => name === key || !name);
-  const dirs = globals.map(([key]) => key).join('|');
   config.module.rules.push({
-    test: new RegExp(`/(${dirs})/index\\.js$`.replace(/\//g, /[/\\]/.source)),
+    test: new RegExp(`/${name}/.*?\\.js$`.replace(/\//g, /[/\\]/.source)),
     use: [{
       loader: './scripts/fake-dep-loader.js',
-      options: {
-        files: globals.map(([, path]) => path),
-      },
+      options: { files: entryGlobals[name] },
     }],
   });
   const reader = () => (
-    globals.map(([, path]) => (
-      fs.readFileSync(path, { encoding: 'utf8' })
-      .replace(/export\s+(?=const\s)/g, '')
-    ))
-  ).join('\n');
+    entryGlobals[name]
+    .map(path => fs.readFileSync(path, { encoding: 'utf8' }))
+    .join('\n')
+    .replace(/export\s+(?=(const|let)\s)/g, '')
+    .replace(defsRe, s => defsObj[s])
+  );
   config.plugins.push(new WrapperWebpackPlugin(callback(reader)));
 };
 
@@ -148,21 +169,24 @@ module.exports = Promise.all([
         && Object.assign(p.options, { ignoreOrder: true })
       ));
     }
-    config.plugins.push(new ListBackgroundScriptsPlugin());
+    config.plugins.push(new ListBackgroundScriptsPlugin({
+      minify: false, // keeping readable
+    }));
   }),
 
   modify('injected', './src/injected', (config) => {
-    addWrapper(config, getGlobals => ({
+    addWrapper(config, 'injected/content', getGlobals => ({
       header: () => `${skipReinjectionHeader} { ${getGlobals()}`,
       footer: '}',
     }));
   }),
 
   modify('injected-web', './src/injected/web', (config) => {
+    // TODO: replace WebPack's Object.*, .call(), .apply() with safe calls
     config.output.libraryTarget = 'commonjs2';
-    addWrapper(config, getGlobals => ({
+    addWrapper(config, 'injected/web', getGlobals => ({
       header: () => `${skipReinjectionHeader}
-        window['${INIT_FUNC_NAME}'] = function () {
+        window['${INIT_FUNC_NAME}'] = function (${VAULT_ID}, IS_FIREFOX) {
           var module = { exports: {} };
           ${getGlobals()}`,
       footer: `

+ 13 - 7
src/background/index.js

@@ -48,16 +48,15 @@ Object.assign(commands, {
       resetValueOpener(tabId);
       clearRequestsByTabId(tabId);
     }
-    const res = await getInjectedScripts(url, tabId, frameId);
-    const { feedback, valOpIds } = res._tmp;
-    res.isPopupShown = popupTabs[tabId];
+    const { feedback, inject, valOpIds } = await getInjectedScripts(url, tabId, frameId);
+    inject.isPopupShown = popupTabs[tabId];
     // Injecting known content scripts without waiting for InjectionFeedback message.
     // Running in a separate task because it may take a long time to serialize data.
     if (feedback.length) {
       setTimeout(commands.InjectionFeedback, 0, { feedback }, src);
     }
     addValueOpener(tabId, frameId, valOpIds);
-    return res;
+    return inject;
   },
   /** @return {Promise<Object>} */
   async GetTabDomain() {
@@ -133,10 +132,17 @@ initialize(() => {
   if (ua.isChrome) {
     // Using declarativeContent to run content scripts earlier than document_start
     const api = global.chrome.declarativeContent;
-    api.onPageChanged.getRules(['inject'], rules => {
-      if (rules.length) return;
+    api.onPageChanged.getRules(async ([rule]) => {
+      const id = rule?.id;
+      const newId = process.env.INIT_FUNC_NAME;
+      if (id === newId) {
+        return;
+      }
+      if (id) {
+        await browser.declarativeContent.onPageChanged.removeRules([id]);
+      }
       api.onPageChanged.addRules([{
-        id: 'inject',
+        id: newId,
         conditions: [
           new api.PageStateMatcher({
             pageUrl: { urlContains: '://' }, // essentially like <all_urls>

+ 40 - 29
src/background/utils/preinject.js

@@ -21,7 +21,11 @@ const TIME_KEEP_DATA = 60e3; // 100ms should be enough but the tab may hang or g
 const cacheCode = initCache({ lifetime: TIME_KEEP_DATA });
 const cache = initCache({
   lifetime: TIME_KEEP_DATA,
-  onDispose: async promise => (await promise).rcsPromise?.unregister(),
+  onDispose: async promise => {
+    const data = await promise;
+    const rcs = await data?.rcsPromise;
+    rcs?.unregister();
+  },
 });
 const KEY_EXPOSE = 'expose';
 const KEY_INJECT_INTO = 'defaultInjectInto';
@@ -40,7 +44,10 @@ Object.assign(commands, {
   async InjectionFeedback({ feedId, feedback, pageInjectable }, src) {
     feedback.forEach(processFeedback, src);
     if (feedId) {
-      const env = await cache.pop(feedId);
+      // cache cleanup when getDataFF outruns GetInjected
+      cache.del(feedId.cacheKey);
+      // envDelayed
+      const env = await cache.pop(feedId.envKey);
       if (env) {
         const { scripts } = env;
         env.forceContent = !pageInjectable;
@@ -122,7 +129,8 @@ function onOptionChanged(changes) {
 
 /** @return {Promise<Object>} */
 export function getInjectedScripts(url, tabId, frameId) {
-  return cache.pop(getKey(url, !frameId)) || prepare(url, tabId, frameId, true);
+  const key = getKey(url, !frameId);
+  return cache.pop(key) || prepare(key, url, tabId, frameId, true);
 }
 
 function getKey(url, isTop) {
@@ -148,7 +156,7 @@ function onSendHeaders({ url, tabId, frameId }) {
     // GetInjected message will be sent soon by the content script
     // and it may easily happen while getScriptsByURL is still waiting for browser.storage
     // so we'll let GetInjected await this pending data by storing Promise in the cache
-    cache.put(key, prepare(url, tabId, frameId), TIME_AFTER_SEND);
+    cache.put(key, prepare(key, url, tabId, frameId), TIME_AFTER_SEND);
   }
 }
 
@@ -157,47 +165,50 @@ function onHeadersReceived(info) {
   cache.hit(getKey(info.url, !info.frameId), TIME_AFTER_RECEIVE);
 }
 
-function prepare(url, tabId, frameId, isLate) {
-  /** @namespace VMGetInjectedData */
+function prepare(key, url, tabId, frameId, isLate) {
   const res = {
-    expose: !frameId
-      && url.startsWith('https://')
-      && expose[url.split('/', 3)[2]],
+    /** @namespace VMGetInjectedData */
+    inject: {
+      expose: !frameId
+        && url.startsWith('https://')
+        && expose[url.split('/', 3)[2]],
+    },
   };
   return isApplied
-    ? prepareScripts(url, tabId, frameId, isLate, res)
+    ? prepareScripts(res, key, url, tabId, frameId, isLate)
     : res;
 }
 
-async function prepareScripts(url, tabId, frameId, isLate, res) {
+async function prepareScripts(res, cacheKey, url, tabId, frameId, isLate) {
   const data = await getScriptsByURL(url, !frameId);
   const { envDelayed, scripts } = data;
   const feedback = scripts.map(prepareScript, data).filter(Boolean);
   const more = envDelayed.promise;
-  const feedId = getUniqId(`${tabId}:${frameId}:`);
+  const envKey = getUniqId(`${tabId}:${frameId}:`);
+  const { inject } = res;
   /** @namespace VMGetInjectedData */
-  Object.assign(res, {
-    feedId, // InjectionFeedback id for envDelayed
+  Object.assign(inject, {
     injectInto,
     scripts,
+    feedId: {
+      cacheKey, // InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected
+      envKey, // InjectionFeedback cache key for envDelayed
+    },
     hasMore: !!more, // tells content bridge to expect envDelayed
     ids: data.disabledIds, // content bridge adds the actually running ids and sends via SetPopup
     info: {
       cache: data.cache,
-      isFirefox: ua.isFirefox,
       ua,
     },
   });
-  Object.defineProperty(res, '_tmp', {
-    value: {
-      feedback,
-      valOpIds: [...data[ENV_VALUE_IDS], ...envDelayed[ENV_VALUE_IDS]],
-    },
+  Object.assign(res, {
+    feedback,
+    valOpIds: [...data[ENV_VALUE_IDS], ...envDelayed[ENV_VALUE_IDS]],
+    rcsPromise: !isLate && IS_FIREFOX
+      ? registerScriptDataFF(inject, url, !!frameId)
+      : null,
   });
-  if (!isLate && browser.contentScripts) {
-    registerScriptDataFF(data, res, url, !!frameId);
-  }
-  if (more) cache.put(feedId, more);
+  if (more) cache.put(envKey, more);
   return res;
 }
 
@@ -252,8 +263,9 @@ function replaceWithFullWidthForm(s) {
   return String.fromCharCode(s.charCodeAt(0) - 0x20 + 0xFF00);
 }
 
-const resolveDataCodeStr = `(${(data) => {
-  // not using `window` because this code can't reach its replacement set by guardGlobals
+const resolveDataCodeStr = `(${function _(data) {
+  /* `function` is required to compile `this`, and `this` is required because our safe-globals
+   * shadows `window` so its name is minified and hence inaccessible here */
   const { vmResolve } = this;
   if (vmResolve) {
     vmResolve(data);
@@ -264,9 +276,8 @@ const resolveDataCodeStr = `(${(data) => {
 }})`;
 
 // TODO: rework the whole thing to register scripts individually with real `matches`
-function registerScriptDataFF(data, inject, url, allFrames) {
-  data::forEachEntry(([key]) => delete data[key]); // releasing the contents for garbage collection
-  data.rcsPromise = browser.contentScripts.register({
+function registerScriptDataFF(inject, url, allFrames) {
+  return browser.contentScripts?.register({
     allFrames,
     js: [{
       code: `${resolveDataCodeStr}(${JSON.stringify(inject)})`,

+ 32 - 20
src/common/browser.js

@@ -2,40 +2,45 @@
 // for DOM elements with 'id' attribute which is a standard feature, more info:
 // https://github.com/mozilla/webextension-polyfill/pull/153
 // https://html.spec.whatwg.org/multipage/window-object.html#named-access-on-the-window-object
-if (!global.browser?.runtime?.sendMessage) {
+if (!IS_FIREFOX && !global.browser?.runtime) {
   // region Chrome
-  const { chrome, Error, Promise, Proxy } = global;
+  const { chrome, Proxy: ProxySafe } = global;
+  const { apply, bind } = ProxySafe;
   const MESSAGE = 'message';
   const STACK = 'stack';
-  /** onXXX like onMessage */
-  const isApiEvent = key => key[0] === 'o' && key[1] === 'n';
+  const isSyncMethodName = key => key === 'addListener'
+    || key === 'removeListener'
+    || key === 'hasListener'
+    || key === 'hasListeners';
   /** API types or enums or literal constants */
   const isFunction = val => typeof val === 'function';
   const isObject = val => typeof val === 'object';
-  const proxifyValue = (target, key, groupName, src, metaVal) => {
+  const proxifyValue = (target, key, src, metaVal) => {
     const srcVal = src[key];
     if (srcVal === undefined) return;
     let res;
     if (isFunction(metaVal)) {
       res = metaVal(src, srcVal);
     } else if (isFunction(srcVal)) {
-      res = metaVal === 0 || isApiEvent(groupName)
+      res = metaVal === 0 || isSyncMethodName(key) || !src::hasOwnProperty(key)
         ? srcVal::bind(src)
         : wrapAsync(src, srcVal); // eslint-disable-line no-use-before-define
     } else if (isObject(srcVal) && metaVal !== 0) {
-      res = proxifyGroup(key, srcVal, metaVal); // eslint-disable-line no-use-before-define
+      res = proxifyGroup(srcVal, metaVal); // eslint-disable-line no-use-before-define
     } else {
       res = srcVal;
     }
     target[key] = res;
     return res;
   };
-  const proxifyGroup = (groupName, src, meta) => new Proxy({}, {
-    get: (target, key) => (
-      target[key]
-      ?? proxifyValue(target, key, groupName, src, meta?.[key])
-    ),
+  const proxifyGroup = (src, meta) => new ProxySafe({}, {
+    get: (group, key) => group[key] ?? proxifyValue(group, key, src, meta?.[key]),
   });
+  /**
+   * @param {Object} thisArg - original API group
+   * @param {function} func - original API function
+   * @param {WrapAsyncPreprocessorFunc} [preprocessorFunc] - modifies the API callback's response
+    */
   const wrapAsync = (thisArg, func, preprocessorFunc) => (
     (...args) => {
       let resolve;
@@ -43,12 +48,12 @@ if (!global.browser?.runtime?.sendMessage) {
       /* Using resolve/reject to call API in the scope of this function, not inside Promise,
          because an API validation exception is thrown synchronously both in Chrome and FF
          so the caller can use try/catch to detect it like we've been doing in icon.js */
-      const promise = new Promise((_resolve, _reject) => {
+      const promise = new PromiseSafe((_resolve, _reject) => {
         resolve = _resolve;
         reject = _reject;
       });
       // Make the error messages actually useful by capturing a real stack
-      const stackInfo = new Error(`callstack before invoking ${func.name || 'chrome API'}:`);
+      const stackInfo = new ErrorSafe(`callstack before invoking ${func.name || 'chrome API'}:`);
       // A single parameter `result` is fine because we don't use API that return more
       args[args.length] = result => {
         const runtimeErr = chrome.runtime.lastError;
@@ -78,7 +83,7 @@ if (!global.browser?.runtime?.sendMessage) {
   // Both result and error must be explicitly specified to avoid prototype eavesdropping
   const wrapError = err => process.env.DEBUG && console.warn(err) || [
     null,
-    err instanceof Error
+    err instanceof ErrorSafe
       ? [err[MESSAGE], err[STACK]]
       : [err, ''],
   ];
@@ -95,7 +100,7 @@ if (!global.browser?.runtime?.sendMessage) {
     if (process.env.DEBUG) console.info('receive', message);
     try {
       const result = listener(message, sender);
-      if (result instanceof Promise) {
+      if (result && result::objectToString() === '[object Promise]') {
         sendResponseAsync(result, sendResponse);
         return true;
       }
@@ -109,7 +114,7 @@ if (!global.browser?.runtime?.sendMessage) {
       sendResponse(wrapError(err));
     }
   };
-  /** @returns {?} error */
+  /** @type {WrapAsyncPreprocessorFunc} */
   const unwrapResponse = (resolve, response) => (
     !response && 'null response'
     || response[1] // error created in wrapError
@@ -122,7 +127,7 @@ if (!global.browser?.runtime?.sendMessage) {
    * 0 = non-async method or the entire group
    * function = transformer like (originalObj, originalFunc): function
    */
-  global.browser = proxifyGroup('', chrome, {
+  global.browser = proxifyGroup(chrome, {
     extension: 0, // we don't use its async methods
     i18n: 0, // we don't use its async methods
     runtime: {
@@ -142,7 +147,7 @@ if (!global.browser?.runtime?.sendMessage) {
     },
   });
   // endregion
-} else if (process.env.DEBUG && !global.chrome.app) {
+} else if (process.env.DEBUG && IS_FIREFOX) {
   // region Firefox
   /* eslint-disable no-restricted-syntax */// this is a debug-only section
   let counter = 0;
@@ -169,10 +174,17 @@ if (!global.browser?.runtime?.sendMessage) {
     const { frameId, tab, url } = sender;
     log('on', [msg, { frameId, tab, url }], id);
     const result = listener(msg, sender);
-    (typeof result?.then === 'function' ? result : Promise.resolve(result))
+    (typeof result?.then === 'function' ? result : PromiseSafe.resolve(result))
     .then(data => log('on', [data], id, true), console.warn);
     return result;
   });
   /* eslint-enable no-restricted-syntax */
   // endregion
 }
+
+/**
+ * @callback WrapAsyncPreprocessorFunc
+ * @param {function(any)} resolve - called on success
+ * @param {any} response - API callback's response
+ * @returns {?string[]} - [errorMessage, errorStack] array on error
+ */

+ 21 - 9
src/common/safe-globals.js

@@ -1,15 +1,27 @@
 /* eslint-disable no-unused-vars */
 
-const global = (function _() { return this || {}; }());
-// Not exporting the built-in globals because this also runs in node
+/**
+ * This file is used by entire `src` except `injected`.
+ * `global` is used instead of WebPack's polyfill which we disable in webpack.conf.js.
+ * `safeCall` is used by our modified babel-plugin-safe-bind.js.
+ * Standard globals are extracted for better minification and marginally improved lookup speed.
+ * Not exporting NodeJS built-in globals as this file is imported in the test scripts.
+ * WARNING! Don't use modern JS syntax like ?. or ?? as this file isn't preprocessed by Babel.
+ */
+
+const global = (function _() {
+  return this || globalThis; // eslint-disable-line no-undef
+}());
 const {
-  Array, Boolean, Object, Promise, Uint8Array,
-  addEventListener, removeEventListener,
-  /* per spec `document` can change only in about:blank but we don't inject there
-     https://html.spec.whatwg.org/multipage/window-object.html#dom-document-dev */
+  Boolean,
+  Error,
+  Object,
+  Promise,
   document,
   window,
 } = global;
-export const { hasOwnProperty } = {};
-export const { apply, bind, call } = hasOwnProperty;
-export const safeCall = call.bind(call);
+export const PromiseSafe = Promise; // alias used by browser.js
+export const ErrorSafe = Error; // alias used by browser.js
+export const { hasOwnProperty, toString: objectToString } = {};
+export const safeCall = Object.call.bind(Object.call);
+export const IS_FIREFOX = !global.chrome.app;

+ 4 - 1
src/common/ua.js

@@ -2,14 +2,17 @@
 // so we'll test for window.chrome.app which is only defined in Chrome
 // and for browser.runtime.getBrowserInfo in Firefox 51+
 
-/** @typedef UA
+/** @typedef UAExtras
  * @property {false | number} isChrome - Chrome/ium version number or `false`
  * @property {Boolean | number} isFirefox - boolean initially, Firefox version number when ready
+ */
+/** @typedef UAInjected
  * @property {chrome.runtime.PlatformInfo.arch} arch
  * @property {chrome.runtime.PlatformInfo.os} os
  * @property {string} browserName
  * @property {string} browserVersion
  */
+/** @type {UAInjected & UAExtras} */
 const ua = {};
 export default ua;
 

+ 3 - 0
src/common/util.js

@@ -9,6 +9,9 @@ const perfNow = performance.now.bind(performance);
 const { random, floor } = Math;
 const { toString: numberToString } = 0;
 
+export const isPromise = val => val::objectToString() === '[object Promise]';
+export const isFunction = val => typeof val === 'function';
+
 export function i18n(name, args) {
   return browser.i18n.getMessage(name, args) || name;
 }

+ 15 - 10
src/injected/content/bridge.js

@@ -1,5 +1,6 @@
-import { sendCmd } from '#/common';
+import { isPromise, sendCmd } from '#/common';
 import { INJECT_PAGE, browser } from '#/common/consts';
+import { CALLBACK_ID, createNullObj, getOwnProp } from '../util';
 
 const allow = createNullObj();
 /** @type {Object.<string, MessageFromGuestHandler>} */
@@ -14,7 +15,7 @@ const assignHandlers = (dest, src, force) => {
   }
 };
 const bridge = {
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   ids: [], // all ids including the disabled ones for SetPopup
   runningIds: [],
   // userscripts running in the content script context are messaged via invokeGuest
@@ -35,19 +36,23 @@ const bridge = {
     (allow[cmd] || (allow[cmd] = createNullObj()))[dataKey] = true;
   },
   // realm is provided when called directly via invokeHost
-  async onHandle({ cmd, data, dataKey }, realm) {
+  async onHandle({ cmd, data, dataKey, node }, realm) {
     const handle = handlers[cmd];
     if (!handle || !allow[cmd]?.[dataKey]) {
-      throw new Error(`[Violentmonkey] Invalid command: "${cmd}" on ${global.location.host}`);
+      throw new ErrorSafe(`[Violentmonkey] Invalid command: "${cmd}" on ${global.location.host}`);
     }
-    const callbackId = data?.callbackId;
-    const payload = callbackId ? data.payload : data;
-    let res = handle === true ? sendCmd(cmd, payload) : handle(payload, realm || INJECT_PAGE);
-    if (res instanceof Promise) {
+    const callbackId = data && data::getOwnProp(CALLBACK_ID);
+    if (callbackId) {
+      data = data.data;
+    }
+    let res = handle === true
+      ? sendCmd(cmd, data)
+      : node::handle(data, realm || INJECT_PAGE);
+    if (res && isPromise(res)) {
       res = await res;
     }
-    if (callbackId && res !== undefined) {
-      bridge.post('Callback', { callbackId, payload: res }, realm);
+    if (callbackId) {
+      bridge.post('Callback', { id: callbackId, data: res }, realm);
     }
   },
 };

+ 8 - 8
src/injected/content/clipboard.js

@@ -1,15 +1,15 @@
-import { log } from '../utils/helpers';
+import { log } from '../util';
 import bridge from './bridge';
 
 bridge.onScripts.push(() => {
   let setClipboard;
-  if (bridge.isFirefox) {
+  if (IS_FIREFOX) {
     let clipboardData;
     // old Firefox defines it on a different prototype so we'll just grab it from document directly
     const { execCommand } = document;
-    const { setData } = DataTransfer[Prototype];
-    const { get: getClipboardData } = describeProperty(ClipboardEvent[Prototype], 'clipboardData');
-    const { preventDefault, stopImmediatePropagation } = Event[Prototype];
+    const { setData } = DataTransfer[PROTO];
+    const { get: getClipboardData } = describeProperty(ClipboardEvent[PROTO], 'clipboardData');
+    const { preventDefault, stopImmediatePropagation } = Event[PROTO];
     const onCopy = e => {
       e::stopImmediatePropagation();
       e::preventDefault();
@@ -17,16 +17,16 @@ bridge.onScripts.push(() => {
     };
     setClipboard = params => {
       clipboardData = params;
-      document::addEventListener('copy', onCopy);
+      document::on('copy', onCopy);
       if (!document::execCommand('copy') && process.env.DEBUG) {
         log('warn', null, 'GM_setClipboard failed!');
       }
-      document::removeEventListener('copy', onCopy);
+      document::off('copy', onCopy);
       clipboardData = null;
     };
   }
   bridge.addHandlers({
-    __proto__: null, // Object.create(null) may be spoofed
+    __proto__: null,
     SetClipboard: setClipboard || true,
   }, true);
 });

+ 82 - 45
src/injected/content/index.js

@@ -1,25 +1,26 @@
 import { getUniqId, isEmpty, sendCmd } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
-import { objectPick } from '#/common/object';
-import { bindEvents } from '../utils';
-import { NS_HTML } from '../utils/helpers';
 import bridge from './bridge';
 import './clipboard';
-import { appendToRoot, injectPageSandbox, injectScripts } from './inject';
+import { injectPageSandbox, injectScripts } from './inject';
 import './notifications';
 import './requests';
 import './tabs';
+import { elemByTag } from './util-content';
+import { NS_HTML, bindEvents, createNullObj, promiseResolve } from '../util';
 
-const IS_FIREFOX = !global.chrome.app;
-const IS_TOP = window.top === window;
-const { invokableIds } = bridge;
-const menus = {};
+const { invokableIds, runningIds } = bridge;
+const menus = createNullObj();
+const resolvedPromise = promiseResolve();
+let ids;
+let injectInto;
+let badgePromise;
+let numBadgesSent = 0;
+let bfCacheWired;
 let isPopupShown;
 let pendingSetPopup;
 
 // Make sure to call obj::method() in code that may run after INJECT_CONTENT userscripts
-const { split } = '';
-
 (async () => {
   const contentId = getUniqId();
   const webId = getUniqId();
@@ -33,18 +34,17 @@ const { split } = '';
     { retry: true });
   const isXml = document instanceof XMLDocument;
   if (!isXml) injectPageSandbox(contentId, webId);
+  // Binding now so iframe's injectPageSandbox can call our bridge.post before `data` is received
+  bindEvents(contentId, webId, bridge, global.cloneInto);
   // detecting if browser.contentScripts is usable, it was added in FF59 as well as composedPath
-  const data = IS_FIREFOX && Event[Prototype].composedPath
+  const data = IS_FIREFOX && Event[PROTO].composedPath
     ? await getDataFF(dataPromise)
     : await dataPromise;
   const { allow } = bridge;
-  // 1) bridge.post may be overridden in injectScripts
-  // 2) cloneInto is provided by Firefox in content scripts to expose data to the page
-  bindEvents(contentId, webId, bridge, global.cloneInto);
-  bridge.contentId = contentId;
-  bridge.ids = data.ids;
-  bridge.isFirefox = data.info.isFirefox;
-  bridge.injectInto = data.injectInto;
+  ids = data.ids;
+  injectInto = data.injectInto;
+  bridge.ids = ids;
+  bridge.injectInto = injectInto;
   isPopupShown = data.isPopupShown;
   if (data.expose) {
     allow('GetScriptVer', contentId);
@@ -63,7 +63,7 @@ const { split } = '';
 })().catch(IS_FIREFOX && console.error); // Firefox can't show exceptions in content scripts
 
 bridge.addBackgroundHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   PopupShown(state) {
     isPopupShown = state;
     sendSetPopup();
@@ -71,10 +71,9 @@ bridge.addBackgroundHandlers({
 }, true);
 
 bridge.addBackgroundHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   Command(data) {
-    const id = +data[0]::split(':', 1)[0];
-    const realm = invokableIds::includes(id) && INJECT_CONTENT;
+    const realm = invokableIds::includes(data.id) && INJECT_CONTENT;
     bridge.post('Command', data, realm);
   },
   UpdatedValues(data) {
@@ -89,39 +88,61 @@ bridge.addBackgroundHandlers({
 });
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
-  RegisterMenu(data) {
+  __proto__: null,
+  RegisterMenu({ id, cap }) {
     if (IS_TOP) {
-      const id = data[0];
-      const cap = data[1];
-      const commandMap = menus[id] || (menus[id] = {});
+      const commandMap = menus[id] || (menus[id] = createNullObj());
       commandMap[cap] = 1;
       sendSetPopup(true);
     }
   },
-  UnregisterMenu(data) {
+  UnregisterMenu({ id, cap }) {
     if (IS_TOP) {
-      const id = data[0];
-      const cap = data[1];
       delete menus[id]?.[cap];
       sendSetPopup(true);
     }
   },
-  AddElement({ tag, attributes, id }) {
+  /** @this {Node} */
+  AddElement({ tag, attrs, cbId }, realm) {
+    let el;
+    let res;
     try {
-      const el = document::createElementNS(NS_HTML, tag);
-      el::setAttribute('id', id);
-      if (attributes) {
-        objectKeys(attributes)::forEach(key => {
-          if (key === 'textContent') el::append(attributes[key]);
-          else if (key !== 'id') el::setAttribute(key, attributes[key]);
+      const parent = this
+        || /^(script|style|link|meta)$/i::regexpTest(tag) && elemByTag('head')
+        || elemByTag('body')
+        || elemByTag('*');
+      el = document::createElementNS(NS_HTML, tag);
+      if (attrs) {
+        objectKeys(attrs)::forEach(key => {
+          if (key === 'textContent') el::append(attrs[key]);
+          else el::setAttribute(key, attrs[key]);
         });
       }
-      appendToRoot(el);
+      parent::appendChild(el);
     } catch (e) {
       // A page-mode userscript can't catch DOM errors in a content script so we pass it explicitly
       // TODO: maybe move try/catch to bridge.onHandle and use bridge.sendSync in all web commands
-      return e.stack;
+      res = [`${e}`, e.stack];
+    }
+    bridge.post('Callback', { id: cbId, data: res }, realm, el);
+  },
+  Run(id, realm) {
+    runningIds::push(id);
+    ids::push(id);
+    if (realm === INJECT_CONTENT) {
+      invokableIds::push(id);
+    }
+    if (!badgePromise) {
+      badgePromise = resolvedPromise::then(throttledSetBadge);
+    }
+    if (!bfCacheWired) {
+      bfCacheWired = true;
+      window::on('pageshow', evt => {
+        // isTrusted is `unforgeable` per DOM spec so we don't need to safeguard its getter
+        if (evt.isTrusted && evt.persisted) {
+          sendCmd('SetBadge', runningIds);
+        }
+      });
     }
   },
   SetTimeout: true,
@@ -129,6 +150,16 @@ bridge.addHandlers({
   UpdateValue: true,
 });
 
+function throttledSetBadge() {
+  const num = runningIds.length;
+  if (numBadgesSent < num) {
+    numBadgesSent = num;
+    return sendCmd('SetBadge', runningIds)::then(() => {
+      badgePromise = throttledSetBadge();
+    });
+  }
+}
+
 async function sendSetPopup(isDelayed) {
   if (isPopupShown) {
     if (isDelayed) {
@@ -138,17 +169,23 @@ async function sendSetPopup(isDelayed) {
       await pendingSetPopup;
       pendingSetPopup = null;
     }
-    sendCmd('SetPopup',
-      assign({ menus }, objectPick(bridge, ['ids', 'failedIds', 'runningIds', 'injectInto'])));
+    sendCmd('SetPopup', {
+      ids,
+      injectInto,
+      menus,
+      runningIds,
+      failedIds: bridge.failedIds,
+    });
   }
 }
 
 async function getDataFF(viaMessaging) {
-  const data = window.vmData || await Promise.race([
-    new Promise(resolve => { window.vmResolve = resolve; }),
+  // In Firefox we set data on global `this` which is not equal to `window`
+  const data = global.vmData || await PromiseSafe.race([
+    new PromiseSafe(resolve => { global.vmResolve = resolve; }),
     viaMessaging,
   ]);
-  delete window.vmResolve;
-  delete window.vmData;
+  delete global.vmResolve;
+  delete global.vmData;
   return data;
 }

+ 60 - 126
src/injected/content/inject.js

@@ -1,76 +1,52 @@
 import { INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser } from '#/common/consts';
-import { sendCmd } from '#/common';
+import { getUniqId, sendCmd } from '#/common';
 import { forEachKey } from '#/common/object';
-import { elemByTag, NS_HTML, log } from '../utils/helpers';
 import bridge from './bridge';
+import { allowCommands, appendToRoot, onElement } from './util-content';
+import { NS_HTML, log } from '../util';
 
-// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1408996
-let VMInitInjection = window[process.env.INIT_FUNC_NAME];
-// To avoid running repeatedly due to new `document.documentElement`
-// (the prop is undeletable so a userscript can't fool us on reinjection)
-defineProperty(window, process.env.INIT_FUNC_NAME, { value: 1 });
-
-const regexpTest = RegExp[Prototype].test;
+const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
 const stringIncludes = ''.includes;
-const resolvedPromise = Promise.resolve();
-const { allow, runningIds } = bridge;
 let contLists;
 let pgLists;
 /** @type {Object<string,VMInjectionRealm>} */
 let realms;
 /** @type boolean */
 let pageInjectable;
-let badgePromise;
-let numBadgesSent = 0;
-let bfCacheWired;
+let frameEventDocument;
+
+// https://bugzil.la/1408996
+let VMInitInjection = window[INIT_FUNC_NAME];
+/** Avoid running repeatedly due to new `documentElement` or with declarativeContent in Chrome.
+ * The prop's mode is overridden to be unforgeable by a userscript in content mode. */
+defineProperty(window, INIT_FUNC_NAME, {
+  value: 1,
+  configurable: false,
+  enumerable: false,
+  writable: false,
+});
+window::on(INIT_FUNC_NAME, evt => {
+  if (!frameEventDocument) {
+    // injectPageSandbox's first event is the frame's document
+    frameEventDocument = evt::getRelatedTarget();
+  } else {
+    // injectPageSandbox's second event is the vaultId
+    bridge.post('Frame', evt::getDetail(), INJECT_PAGE, frameEventDocument);
+    frameEventDocument = null;
+  }
+});
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   // FF bug workaround to enable processing of sourceURL in injected page scripts
   InjectList: injectList,
-  Run(id, realm) {
-    runningIds::push(id);
-    bridge.ids::push(id);
-    if (realm === INJECT_CONTENT) {
-      bridge.invokableIds::push(id);
-    }
-    if (!badgePromise) {
-      badgePromise = resolvedPromise::then(throttledSetBadge);
-    }
-    if (!bfCacheWired) {
-      bfCacheWired = true;
-      window::addEventListener('pageshow', evt => {
-        // isTrusted is `unforgeable` per DOM spec so we don't need to safeguard its getter
-        if (evt.isTrusted && evt.persisted) {
-          sendCmd('SetBadge', runningIds);
-        }
-      });
-    }
-  },
 });
 
-function throttledSetBadge() {
-  const num = runningIds.length;
-  if (numBadgesSent < num) {
-    numBadgesSent = num;
-    return sendCmd('SetBadge', runningIds)::then(() => {
-      badgePromise = throttledSetBadge();
-    });
-  }
-}
-
-export function appendToRoot(node) {
-  // DOM spec allows any elements under documentElement
-  // https://dom.spec.whatwg.org/#node-trees
-  const root = elemByTag('head') || elemByTag('*');
-  return root && root::appendChild(node);
-}
-
 export function injectPageSandbox(contentId, webId) {
+  const vaultId = !IS_TOP && setupVaultId() || '';
   inject({
-    code: `(${VMInitInjection}())('${webId}','${contentId}')\n//# sourceURL=${
-      browser.runtime.getURL('sandbox/injected-web.js')
-    }`,
+    code: `(${VMInitInjection}('${vaultId}',${IS_FIREFOX}))('${webId}','${contentId}')`
+      + `\n//# sourceURL=${browser.runtime.getURL('sandbox/injected-web.js')}`,
   });
 }
 
@@ -84,7 +60,7 @@ export async function injectScripts(contentId, webId, data, isXml) {
   const { hasMore, info } = data;
   pageInjectable = isXml ? false : null;
   realms = {
-    __proto__: null, // Object.create(null) may be spoofed
+    __proto__: null,
     /** @namespace VMInjectionRealm */
     [INJECT_CONTENT]: {
       injectable: () => true,
@@ -121,7 +97,7 @@ export async function injectScripts(contentId, webId, data, isXml) {
     pageInjectable: pageInjectable ?? (hasMore && checkInjectable()),
   });
   // saving while safe
-  const getReadyState = hasMore && describeProperty(Document[Prototype], 'readyState').get;
+  const getReadyState = hasMore && describeProperty(Document[PROTO], 'readyState').get;
   const hasInvoker = realms[INJECT_CONTENT].is;
   if (hasInvoker) {
     setupContentInvoker(contentId, webId);
@@ -129,7 +105,7 @@ export async function injectScripts(contentId, webId, data, isXml) {
   // Using a callback to avoid a microtask tick when the root element exists or appears.
   await onElement('*', async () => {
     injectAll('start');
-    const onBody = (pgLists.body[0] || contLists.body[0])
+    const onBody = (pgLists.body.length || contLists.body.length)
       && onElement('body', injectAll, 'body');
     // document-end, -idle
     if (hasMore) {
@@ -161,10 +137,10 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }, getRea
     script.stage = !code && runAt;
   });
   if (document::getReadyState() === 'loading') {
-    await new Promise(resolve => {
+    await new PromiseSafe(resolve => {
       /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
        * by listening on `window` which follows `document` when the event bubbles up. */
-      window::addEventListener('DOMContentLoaded', resolve, { once: true });
+      window::on('DOMContentLoaded', resolve, { once: true });
     });
   }
   if (needsInvoker && contentId) {
@@ -189,7 +165,7 @@ function inject(item) {
   const script = document::createElementNS(NS_HTML, 'script');
   // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
   let onError;
-  if (bridge.isFirefox) {
+  if (IS_FIREFOX) {
     onError = e => {
       const { stack } = e.error;
       if (typeof stack === 'string' && stack::stringIncludes(browser.runtime.getURL('/sandbox'))) {
@@ -197,13 +173,13 @@ function inject(item) {
         e.preventDefault();
       }
     };
-    window::addEventListener('error', onError);
+    window::on('error', onError);
   }
   // using a safe call to an existing method so we don't have to extract textContent setter
   script::append(item.code);
   // When using declarativeContent there's no documentElement so we'll append to `document`
   if (!appendToRoot(script)) document::appendChild(script);
-  if (onError) window::removeEventListener('error', onError);
+  if (onError) window::off('error', onError);
   script::remove();
 }
 
@@ -211,9 +187,11 @@ function injectAll(runAt) {
   realms::forEachKey((realm) => {
     const realmData = realms[realm];
     const items = realmData.lists[runAt];
+    const { info } = realmData;
     if (items.length) {
-      bridge.post('ScriptData', { items, runAt, info: realmData.info }, realm);
-      if (realm === INJECT_PAGE && !bridge.isFirefox) {
+      bridge.post('ScriptData', { info, items, runAt }, realm);
+      info.cache = null;
+      if (realm === INJECT_PAGE && !IS_FIREFOX) {
         injectList(runAt);
       }
     }
@@ -236,71 +214,27 @@ async function injectList(runAt) {
   }
 }
 
-/**
- * @param {string} tag
- * @param {function} cb - callback runs immediately, unlike a chained then()
- * @param {?} [arg]
- * @returns {Promise<void>}
- */
-function onElement(tag, cb, arg) {
-  return new Promise(resolve => {
-    if (elemByTag(tag)) {
-      cb(arg);
-      resolve();
-    } else {
-      const observer = new MutationObserver(() => {
-        if (elemByTag(tag)) {
-          observer.disconnect();
-          cb(arg);
-          resolve();
-        }
-      });
-      // documentElement may be replaced so we'll observe the entire document
-      observer.observe(document, { childList: true, subtree: true });
-    }
-  });
-}
-
 function setupContentInvoker(contentId, webId) {
-  const invokeContent = VMInitInjection()(webId, contentId, bridge.onHandle);
+  const invokeContent = VMInitInjection('', IS_FIREFOX)(webId, contentId, bridge.onHandle);
   const postViaBridge = bridge.post;
-  bridge.post = (cmd, params, realm) => (
-    (realm === INJECT_CONTENT ? invokeContent : postViaBridge)(cmd, params)
-  );
+  bridge.post = (cmd, params, realm, node) => {
+    const fn = realm === INJECT_CONTENT
+      ? invokeContent
+      : postViaBridge;
+    fn(cmd, params, undefined, node);
+  };
   VMInitInjection = null; // release for GC
 }
 
-/**
- * @param {VMInjectedScript | VMScript} script
- */
-function allowCommands(script) {
-  const { dataKey } = script;
-  allow('Run', dataKey);
-  script.meta.grant::forEach(grant => {
-    const gm = /^GM[._]/::regexpTest(grant) && grant::slice(3);
-    if (grant === 'GM_xmlhttpRequest' || grant === 'GM.xmlHttpRequest' || gm === 'download') {
-      allow('AbortRequest', dataKey);
-      allow('HttpRequest', dataKey);
-    } else if (grant === 'window.close') {
-      allow('TabClose', dataKey);
-    } else if (grant === 'window.focus') {
-      allow('TabFocus', dataKey);
-    } else if (gm === 'addElement' || gm === 'addStyle') {
-      allow('AddElement', dataKey);
-    } else if (gm === 'setValue' || gm === 'deleteValue') {
-      allow('UpdateValue', dataKey);
-    } else if (gm === 'notification') {
-      allow('Notification', dataKey);
-      allow('RemoveNotification', dataKey);
-    } else if (gm === 'openInTab') {
-      allow('TabOpen', dataKey);
-      allow('TabClose', dataKey);
-    } else if (gm === 'registerMenuCommand') {
-      allow('RegisterMenu', dataKey);
-    } else if (gm === 'setClipboard') {
-      allow('SetClipboard', dataKey);
-    } else if (gm === 'unregisterMenuCommand') {
-      allow('UnregisterMenu', dataKey);
-    }
-  });
+function setupVaultId() {
+  const { parent } = window;
+  // Testing for same-origin parent without throwing an exception.
+  if (describeProperty(parent.location, 'href').get) {
+    const vaultId = getUniqId();
+    // In FF, content scripts running in a same-origin frame cannot directly call parent's functions
+    // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
+    parent::fire(new MouseEventSafe(INIT_FUNC_NAME, { relatedTarget: document }));
+    parent::fire(new CustomEventSafe(INIT_FUNC_NAME, { detail: vaultId }));
+    return vaultId;
+  }
 }

+ 8 - 6
src/injected/content/notifications.js

@@ -1,25 +1,27 @@
 import { sendCmd } from '#/common';
 import bridge from './bridge';
+import { createNullObj } from '../util';
 
 const notifications = createNullObj();
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   async Notification(options, realm) {
     const nid = await sendCmd('Notification', options);
     notifications[nid] = { id: options.id, realm };
   },
   RemoveNotification(id) {
-    const nid = objectEntries(notifications).find(entry => entry[1].id === id)?.[0];
-    if (nid) {
-      delete notifications[nid];
-      return sendCmd('RemoveNotification', nid);
+    for (const nid in notifications) {
+      if (notifications[nid].id === id) {
+        delete notifications[nid];
+        return sendCmd('RemoveNotification', nid);
+      }
     }
   },
 });
 
 bridge.addBackgroundHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   NotificationClick(nid) {
     const n = notifications[nid];
     if (n) bridge.post('NotificationClicked', n.id, n.realm);

+ 4 - 3
src/injected/content/requests.js

@@ -1,13 +1,14 @@
 import { sendCmd } from '#/common';
 import bridge from './bridge';
+import { createNullObj } from '../util';
 
 const { fetch } = global;
-const { arrayBuffer: getArrayBuffer, blob: getBlob } = Response[Prototype];
+const { arrayBuffer: getArrayBuffer, blob: getBlob } = Response[PROTO];
 
 const requests = createNullObj();
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   HttpRequest(opts, realm) {
     requests[opts.id] = {
       realm,
@@ -20,7 +21,7 @@ bridge.addHandlers({
 });
 
 bridge.addBackgroundHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   async HttpRequested(msg) {
     const { blobbed, id, numChunks, type } = msg;
     const req = requests[id];

+ 37 - 0
src/injected/content/safe-globals-content.js

@@ -0,0 +1,37 @@
+/* eslint-disable no-unused-vars */
+
+/**
+ * `safeCall` is used by our modified babel-plugin-safe-bind.js.
+ * `export` is stripped in the final output and is only used for our NodeJS test scripts.
+ * WARNING! Don't use modern JS syntax like ?. or ?? as this file isn't preprocessed by Babel.
+ */
+
+export const {
+  CustomEvent: CustomEventSafe,
+  Error: ErrorSafe,
+  MouseEvent: MouseEventSafe,
+  Object, // for minification and guarding webpack Object(import) calls
+  Promise: PromiseSafe,
+  addEventListener: on,
+  dispatchEvent: fire,
+  removeEventListener: off,
+} = global;
+export const { hasOwnProperty, toString: objectToString } = {};
+export const { apply, call } = hasOwnProperty;
+export const safeCall = call.bind(call);
+export const { forEach, includes, push } = [];
+export const { createElementNS, getElementsByTagName } = document;
+export const { then } = Promise[PROTO];
+export const { slice } = '';
+export const { append, appendChild, remove, setAttribute } = Element[PROTO];
+export const {
+  assign,
+  defineProperty,
+  getOwnPropertyDescriptor: describeProperty,
+  keys: objectKeys,
+} = Object;
+export const regexpTest = RegExp[PROTO].test;
+export const getDetail = describeProperty(CustomEventSafe[PROTO], 'detail').get;
+export const getRelatedTarget = describeProperty(MouseEventSafe[PROTO], 'relatedTarget').get;
+export const logging = assign({ __proto__: null }, console);
+export const IS_FIREFOX = !global.chrome.app;

+ 6 - 5
src/injected/content/tabs.js

@@ -1,12 +1,13 @@
 import { sendCmd } from '#/common';
 import bridge from './bridge';
+import { createNullObj } from '../util';
 
-const tabIds = {};
-const tabKeys = {};
-const realms = {};
+const tabIds = createNullObj();
+const tabKeys = createNullObj();
+const realms = createNullObj();
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   async TabOpen({ key, data }, realm) {
     const { id } = await sendCmd('TabOpen', data);
     tabIds[key] = id;
@@ -22,7 +23,7 @@ bridge.addHandlers({
 });
 
 bridge.addBackgroundHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   TabClosed(id) {
     const key = tabKeys[id];
     const realm = realms[id];

+ 74 - 0
src/injected/content/util-content.js

@@ -0,0 +1,74 @@
+import bridge from './bridge';
+import { getOwnProp } from '../util';
+
+const { allow } = bridge;
+
+/**
+ * @param {VMInjectedScript | VMScript} script
+ */
+export const allowCommands = script => {
+  const { dataKey } = script;
+  allow('Run', dataKey);
+  script.meta.grant::forEach(grant => {
+    const gm = /^GM[._]/::regexpTest(grant) && grant::slice(3);
+    if (grant === 'GM_xmlhttpRequest' || grant === 'GM.xmlHttpRequest' || gm === 'download') {
+      allow('AbortRequest', dataKey);
+      allow('HttpRequest', dataKey);
+    } else if (grant === 'window.close') {
+      allow('TabClose', dataKey);
+    } else if (grant === 'window.focus') {
+      allow('TabFocus', dataKey);
+    } else if (gm === 'addElement' || gm === 'addStyle') {
+      allow('AddElement', dataKey);
+    } else if (gm === 'setValue' || gm === 'deleteValue') {
+      allow('UpdateValue', dataKey);
+    } else if (gm === 'notification') {
+      allow('Notification', dataKey);
+      allow('RemoveNotification', dataKey);
+    } else if (gm === 'openInTab') {
+      allow('TabOpen', dataKey);
+      allow('TabClose', dataKey);
+    } else if (gm === 'registerMenuCommand') {
+      allow('RegisterMenu', dataKey);
+    } else if (gm === 'setClipboard') {
+      allow('SetClipboard', dataKey);
+    } else if (gm === 'unregisterMenuCommand') {
+      allow('UnregisterMenu', dataKey);
+    }
+  });
+};
+
+/** When looking for documentElement, use '*' to also support XML pages
+ * Note that we avoid spoofed prototype getters by using hasOwnProperty, and not using `length`
+ * as it searches for ALL matching nodes when this tag wasn't cached internally. */
+export const elemByTag = tag => getOwnProp(document::getElementsByTagName(tag), 0);
+
+export const appendToRoot = node => {
+  // DOM spec allows any elements under documentElement
+  // https://dom.spec.whatwg.org/#node-trees
+  const root = elemByTag('head') || elemByTag('*');
+  return root && root::appendChild(node);
+};
+
+/**
+ * @param {string} tag
+ * @param {function} cb - callback runs immediately, unlike a chained then()
+ * @param {?} [arg]
+ * @returns {Promise<void>}
+ */
+export const onElement = (tag, cb, arg) => new PromiseSafe(resolve => {
+  if (elemByTag(tag)) {
+    cb(arg);
+    resolve();
+  } else {
+    const observer = new MutationObserver(() => {
+      if (elemByTag(tag)) {
+        observer.disconnect();
+        cb(arg);
+        resolve();
+      }
+    });
+    // documentElement may be replaced so we'll observe the entire document
+    observer.observe(document, { childList: true, subtree: true });
+  }
+});

+ 2 - 3
src/injected/index.js

@@ -3,8 +3,7 @@ import { sendCmd } from '#/common';
 import './content';
 
 // Script installation in Firefox as it does not support `onBeforeRequest` for `file:`
-if (!global.chrome.app
-&& global.top === window
+if (IS_FIREFOX && IS_TOP
 && global.location.protocol === 'file:'
 && global.location.pathname.endsWith('.user.js')) {
   (async () => {
@@ -13,7 +12,7 @@ if (!global.chrome.app
       fetch,
       history,
       document: { referrer },
-      Response: { [Prototype]: { text: getText } },
+      Response: { [PROTO]: { text: getText } },
       location: { href: url },
     } = global;
     const fetchOpts = { mode: 'same-origin' };

+ 17 - 0
src/injected/safe-globals-injected.js

@@ -0,0 +1,17 @@
+/* eslint-disable no-unused-vars */
+
+/**
+ * This file is used by both `injected` and `injected-web` entries.
+ * `global` is used instead of WebPack's polyfill which we disable in webpack.conf.js.
+ * `export` is stripped in the final output and is only used for our NodeJS test scripts.
+ * WARNING! Don't use modern JS syntax like ?. or ?? as this file isn't preprocessed by Babel.
+ */
+
+const global = (function _() {
+  return this || globalThis; // eslint-disable-line no-undef
+}());
+/** These two are unforgeable so we extract them primarily to improve minification.
+ * The document's value can change only in about:blank but we don't inject there. */
+const { document, window } = global;
+export const PROTO = 'prototype';
+export const IS_TOP = window.top === window;

+ 0 - 22
src/injected/safe-injected-globals.js

@@ -1,22 +0,0 @@
-/* eslint-disable no-unused-vars */
-
-// Not exporting the built-in globals because this also runs in node
-const { CustomEvent, Error, dispatchEvent } = global;
-export const Prototype = 'prototype';
-export const { createElementNS, getElementsByTagName } = document;
-export const { then } = Promise[Prototype];
-export const { filter, forEach, includes, indexOf, join, map, push } = [];
-export const { charCodeAt, slice, replace } = '';
-export const { append, appendChild, remove, setAttribute } = Element[Prototype];
-export const { toString: objectToString } = {};
-export const {
-  assign,
-  defineProperty,
-  getOwnPropertyDescriptor: describeProperty,
-  entries: objectEntries,
-  keys: objectKeys,
-  values: objectValues,
-} = Object;
-export const { parse: jsonParse } = JSON;
-// Using __proto__ because Object.create(null) may be spoofed
-export const createNullObj = () => ({ __proto__: null });

+ 71 - 0
src/injected/util/index.js

@@ -0,0 +1,71 @@
+export const NS_HTML = 'http://www.w3.org/1999/xhtml';
+export const CALLBACK_ID = '__CBID';
+
+/** Using __proto__ because Object.create(null) may be spoofed */
+export const createNullObj = () => ({ __proto__: null });
+export const promiseResolve = () => (async () => {})();
+export const getOwnProp = (obj, key) => (
+  obj::hasOwnProperty(key)
+    ? obj[key]
+    : undefined
+);
+
+export const bindEvents = (srcId, destId, bridge, cloneInto) => {
+  /* Using a separate event for `node` because CustomEvent can't transfer nodes,
+   * whereas MouseEvent (and some others) can't transfer objects without stringification. */
+  let incomingNodeEvent;
+  window::on(srcId, e => {
+    if (!incomingNodeEvent) {
+      // CustomEvent is the main message
+      const data = e::getDetail();
+      incomingNodeEvent = data.node && data;
+      if (!incomingNodeEvent) bridge.onHandle(data);
+    } else {
+      // MouseEvent is the second event when the main event has `node: true`
+      incomingNodeEvent.node = e::getRelatedTarget();
+      bridge.onHandle(incomingNodeEvent);
+      incomingNodeEvent = null;
+    }
+  });
+  bridge.post = (cmd, data, { dataKey } = bridge, node) => {
+    // Constructing the event now so we don't send anything if it throws on invalid `node`
+    const evtNode = node && new MouseEventSafe(destId, { relatedTarget: node });
+    const msg = { cmd, data, dataKey, node: !!evtNode };
+    const detail = cloneInto ? cloneInto(msg, document) : msg;
+    const evtMain = new CustomEventSafe(destId, { detail });
+    window::fire(evtMain);
+    if (evtNode) window::fire(evtNode);
+  };
+};
+
+/** args is [tags?, ...rest] */
+export const log = (level, ...args) => {
+  let s = '[Violentmonkey]';
+  if (args[0]) args[0]::forEach(tag => { s += `[${tag}]`; });
+  args[0] = s;
+  logging[level]::apply(logging, args);
+};
+
+/** Workaround for array eavesdropping via prototype setters like '0','1',...
+ * on `push` and `arr[i] = 123`, as well as via getters if you read beyond
+ * its length or from an unassigned `hole`. */
+export function safePush(value) {
+  defineProperty(this, this.length, { value, writable: true, configurable: true });
+}
+
+/**
+ * Picks into `this`
+ * @param {Object} obj
+ * @param {string[]} keys
+ * @returns {Object} same object as `this`
+ */
+export function pickIntoThis(obj, keys) {
+  if (obj) {
+    keys::forEach(key => {
+      if (obj::hasOwnProperty(key)) {
+        this[key] = obj[key];
+      }
+    });
+  }
+  return this;
+}

+ 0 - 58
src/injected/utils/helpers.js

@@ -1,58 +0,0 @@
-export const logging = assign({}, console);
-export const NS_HTML = 'http://www.w3.org/1999/xhtml';
-/** When looking for documentElement, use '*' to also support XML pages */
-export const elemByTag = (tag, i) => document::getElementsByTagName(tag)[i || 0];
-
-// Firefox defines `isFinite` on `global` not on `window`
-const { isFinite } = global; // eslint-disable-line no-restricted-properties
-const { toString: numberToString } = 0;
-const isArray = obj => (
-  // ES3 way, not reliable if prototype is modified
-  // Object.prototype.toString.call(obj) === '[object Array]'
-  // #565 steamcommunity.com has overridden `Array.prototype`
-  // support duck typing
-  obj && typeof obj.length === 'number' && typeof obj.splice === 'function'
-);
-
-// Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON#Polyfill
-const escMap = {
-  '"': '\\"',
-  '\\': '\\\\',
-  '\b': '\\b',
-  '\f': '\\f',
-  '\n': '\\n',
-  '\r': '\\r',
-  '\t': '\\t',
-};
-const escRE = /[\\"\u0000-\u001F\u2028\u2029]/g; // eslint-disable-line no-control-regex
-const escFunc = m => escMap[m] || `\\u${(m::charCodeAt(0) + 0x10000)::numberToString(16)::slice(1)}`;
-// When running in the page context we must beware of sites that override Array#toJSON
-// leading to an invalid result, which is why our jsonDump() ignores toJSON.
-// Thus, we use the native JSON.stringify() only in the content script context and only until
-// a userscript is injected into this context (due to `@inject-into` and/or a CSP problem).
-export function jsonDump(value) {
-  if (value == null) return 'null';
-  const type = typeof value;
-  if (type === 'number') return isFinite(value) ? `${value}` : 'null';
-  if (type === 'boolean') return `${value}`;
-  if (type === 'object') {
-    if (isArray(value)) {
-      return `[${value::map(jsonDump)::join(',')}]`;
-    }
-    if (value::objectToString() === '[object Object]') {
-      const res = objectKeys(value)::map((key) => {
-        const v = value[key];
-        return v !== undefined && `${jsonDump(key)}:${jsonDump(v)}`;
-      });
-      // JSON.stringify skips undefined in objects i.e. {foo: undefined} produces {}
-      return `{${res::filter(Boolean)::join(',')}}`;
-    }
-  }
-  return `"${value::replace(escRE, escFunc)}"`;
-}
-
-/** args is [tags?, ...rest] */
-export function log(level, ...args) {
-  args[0] = `[Violentmonkey]${args[0] ? `[${args[0]::join('][')}]` : ''}`;
-  logging[level]::apply(logging, args);
-}

+ 0 - 11
src/injected/utils/index.js

@@ -1,11 +0,0 @@
-const getDetail = describeProperty(CustomEvent[Prototype], 'detail').get;
-
-export function bindEvents(srcId, destId, bridge, cloneInto) {
-  global::addEventListener(srcId, e => bridge.onHandle(e::getDetail()));
-  bridge.post = (cmd, params, context) => {
-    const data = { cmd, data: params, dataKey: (context || bridge).dataKey };
-    const detail = cloneInto ? cloneInto(data, document) : data;
-    const e = new CustomEvent(destId, { detail });
-    global::dispatchEvent(e);
-  };
-}

+ 12 - 20
src/injected/web/bridge.js

@@ -1,37 +1,29 @@
 import { getUniqId } from '#/common';
+import { CALLBACK_ID, createNullObj } from '../util';
 
 const handlers = createNullObj();
 const callbacks = createNullObj();
+/**
+ * @property {UAInjected} ua
+ */
 const bridge = {
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   cache: createNullObj(),
   callbacks,
   addHandlers(obj) {
     assign(handlers, obj);
   },
-  onHandle({ cmd, data }) {
+  onHandle({ cmd, data, node }) {
     const fn = handlers[cmd];
-    if (fn) fn(data);
+    if (fn) node::fn(data);
   },
-  send(cmd, data, context) {
-    return new Promise(resolve => {
-      postWithCallback(cmd, data, context, resolve);
+  send(cmd, data, context, node) {
+    return new PromiseSafe(resolve => {
+      const id = getUniqId();
+      callbacks[id] = resolve;
+      bridge.post(cmd, { [CALLBACK_ID]: id, data }, context, node);
     });
   },
-  sendSync(cmd, data, context) {
-    let res;
-    postWithCallback(cmd, data, context, payload => { res = payload; });
-    return res;
-  },
 };
 
-function postWithCallback(cmd, data, context, cb) {
-  const id = getUniqId();
-  callbacks[id] = (payload) => {
-    delete callbacks[id];
-    cb(payload);
-  };
-  bridge.post(cmd, { callbackId: id, payload: data }, context);
-}
-
 export default bridge;

+ 43 - 63
src/injected/web/gm-api.js

@@ -1,33 +1,24 @@
 import { dumpScriptValue, getUniqId, isEmpty } from '#/common/util';
-import { objectPick } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
 import { onTabCreate } from './tabs';
-import { atob, onRequestCreate } from './requests';
+import { onRequestCreate } from './requests';
 import { onNotificationCreate } from './notifications';
 import { decodeValue, dumpValue, loadValues, changeHooks } from './gm-values';
-import { jsonDump, log, logging, NS_HTML, elemByTag } from '../utils/helpers';
+import { jsonDump, vmOwnFunc } from './util-web';
+import { NS_HTML, createNullObj, promiseResolve, log, pickIntoThis } from '../util';
 
 const {
-  Blob, MouseEvent, TextDecoder,
-  RegExp: { [Prototype]: { test } },
-  TextDecoder: { [Prototype]: { decode: tdDecode } },
+  TextDecoder,
   URL: { createObjectURL, revokeObjectURL },
 } = global;
-const { findIndex } = [];
-const { lastIndexOf } = '';
-const { dispatchEvent, getElementById } = document;
-const { removeAttribute } = Element[Prototype];
-
-const vmOwnFuncToString = () => '[Violentmonkey property]';
-export const vmOwnFunc = (func, toString) => {
-  defineProperty(func, 'toString', { value: toString || vmOwnFuncToString });
-  return func;
-};
-let downloadChain = Promise.resolve();
+const { decode: tdDecode } = TextDecoder[PROTO];
+const { indexOf: stringIndexOf } = '';
+let downloadChain = promiseResolve();
 
 export function makeGmApi() {
   return {
+    __proto__: null,
     GM_deleteValue(key) {
       const { id } = this;
       const values = loadValues(id);
@@ -82,15 +73,14 @@ export function makeGmApi() {
     GM_removeValueChangeListener(listenerId) {
       const keyHooks = changeHooks[this.id];
       if (!keyHooks) return;
-      objectEntries(keyHooks)::findIndex(keyHook => {
-        const key = keyHook[0];
-        const hooks = keyHook[1];
+      for (const key in keyHooks) { /* proto is null */// eslint-disable-line guard-for-in
+        const hooks = keyHooks[key];
         if (listenerId in hooks) {
           delete hooks[listenerId];
           if (isEmpty(hooks)) delete keyHooks[key];
-          return true;
+          break;
         }
-      });
+      }
       if (isEmpty(keyHooks)) delete changeHooks[this.id];
     },
     GM_getResourceText(name) {
@@ -103,25 +93,26 @@ export function makeGmApi() {
       const { id } = this;
       const key = `${id}:${cap}`;
       store.commands[key] = func;
-      bridge.post('RegisterMenu', [id, cap], this);
+      bridge.post('RegisterMenu', { id, cap }, this);
       return cap;
     },
     GM_unregisterMenuCommand(cap) {
       const { id } = this;
       const key = `${id}:${cap}`;
       delete store.commands[key];
-      bridge.post('UnregisterMenu', [id, cap], this);
+      bridge.post('UnregisterMenu', { id, cap }, this);
     },
     GM_download(arg1, name) {
       // not using ... as it calls Babel's polyfill that calls unsafe Object.xxx
-      let opts = {};
+      const opts = createNullObj();
       let onload;
       if (typeof arg1 === 'string') {
-        opts = { url: arg1, name };
+        opts.url = arg1;
+        opts.name = name;
       } else if (arg1) {
         name = arg1.name;
         onload = arg1.onload;
-        opts = objectPick(arg1, [
+        opts::pickIntoThis(arg1, [
           'url',
           'headers',
           'timeout',
@@ -131,7 +122,7 @@ export function makeGmApi() {
         ]);
       }
       if (!name || typeof name !== 'string') {
-        throw new Error('Required parameter "name" is missing or not a string.');
+        throw new ErrorSafe('Required parameter "name" is missing or not a string.');
       }
       assign(opts, {
         context: { name, onload },
@@ -163,25 +154,26 @@ export function makeGmApi() {
      * @returns {HTMLElement} it also has .then() so it should be compatible with TM and old VM
      */
     GM_addStyle(css) {
-      return webAddElement(null, 'style', { textContent: css }, this, getUniqId('VMst'));
+      return webAddElement(null, 'style', { textContent: css, id: getUniqId('VMst') }, this);
     },
     GM_openInTab(url, options) {
       return onTabCreate(
         options && typeof options === 'object'
-          ? assign({}, options, { url })
+          ? assign(createNullObj(), options, { url })
           : { active: !options, url },
         this,
       );
     },
     GM_notification(text, title, image, onclick) {
       const options = typeof text === 'object' ? text : {
+        __proto__: null,
         text,
         title,
         image,
         onclick,
       };
       if (!options.text) {
-        throw new Error('GM_notification: `text` is required!');
+        throw new ErrorSafe('GM_notification: `text` is required!');
       }
       const id = onNotificationCreate(options, this);
       return {
@@ -196,34 +188,20 @@ export function makeGmApi() {
   };
 }
 
-function webAddElement(parent, tag, attributes, context, useId) {
-  const id = useId || getUniqId('VMel');
+function webAddElement(parent, tag, attrs, context) {
   let el;
+  let errorInfo;
+  const cbId = getUniqId();
+  bridge.callbacks[cbId] = function _(res) {
+    el = this;
+    errorInfo = res;
+  };
+  bridge.post('AddElement', { tag, attrs, cbId }, context, parent);
   // DOM error in content script can't be caught by a page-mode userscript so we rethrow it here
-  let error = bridge.sendSync('AddElement', { tag, attributes, id }, context);
-  if (!error) {
-    try {
-      el = document::getElementById(id);
-      if (!parent && !/^(script|style|link|meta)$/i.test(tag)) {
-        parent = elemByTag('body');
-      }
-      if (parent) {
-        parent::appendChild(el);
-      }
-    } catch (e) {
-      error = e.stack;
-      el::remove();
-    }
-  }
-  if (error) {
-    throw new Error(error);
-  }
-  if (!useId) {
-    if (attributes && 'id' in attributes) {
-      el::setAttribute('id', attributes.id);
-    } else {
-      el::removeAttribute('id');
-    }
+  if (errorInfo) {
+    const err = new ErrorSafe(errorInfo[0]);
+    err.stack += `\n${errorInfo[1]}`;
+    throw err;
   }
   /* A Promise polyfill is not actually necessary because DOM messaging is synchronous,
      but we keep it for compatibility with GM_addStyle in VM of 2017-2019
@@ -247,17 +225,18 @@ function getResource(context, name, isBlob) {
     if (!res) {
       const raw = bridge.cache[context.pathMap[key] || key];
       if (raw) {
-        const dataPos = raw::lastIndexOf(',');
-        const bin = atob(dataPos < 0 ? raw : raw::slice(dataPos + 1));
-        if (isBlob || /[\x80-\xFF]/::test(bin)) {
+        // TODO: move into `content`
+        const dataPos = raw::stringIndexOf(',');
+        const bin = window::atobSafe(dataPos < 0 ? raw : raw::slice(dataPos + 1));
+        if (isBlob || /[\x80-\xFF]/::regexpTest(bin)) {
           const len = bin.length;
-          const bytes = new Uint8Array(len);
+          const bytes = new Uint8ArraySafe(len);
           for (let i = 0; i < len; i += 1) {
             bytes[i] = bin::charCodeAt(i);
           }
           if (isBlob) {
             const type = dataPos < 0 ? '' : raw::slice(0, dataPos);
-            res = createObjectURL(new Blob([bytes], { type }));
+            res = createObjectURL(new BlobSafe([bytes], { type }));
             context.urls[key] = res;
           } else {
             res = new TextDecoder()::tdDecode(bytes);
@@ -274,13 +253,14 @@ function getResource(context, name, isBlob) {
 }
 
 function downloadBlob(res) {
+  // TODO: move into `content`
   const { context: { name, onload }, response } = res;
   const url = createObjectURL(response);
   const a = document::createElementNS(NS_HTML, 'a');
   a::setAttribute('href', url);
   if (name) a::setAttribute('download', name);
   downloadChain = downloadChain::then(async () => {
-    a::dispatchEvent(new MouseEvent('click'));
+    a::fire(new MouseEventSafe('click'));
     revokeBlobAfterTimeout(url);
     try { if (onload) onload(res); } catch (e) { log('error', ['GM_download', 'callback'], e); }
     await bridge.send('SetTimeout', 100);

+ 5 - 6
src/injected/web/gm-values.js

@@ -1,21 +1,20 @@
 import { forEachEntry } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
-import { log } from '../utils/helpers';
-
-const { Number } = global;
+import { createNullObj, log } from '../util';
 
 // Nested objects: scriptId -> keyName -> listenerId -> GMValueChangeListener
 export const changeHooks = createNullObj();
 
 const dataDecoders = {
+  __proto__: null,
   o: jsonParse,
-  n: Number,
+  n: val => +val,
   b: val => val === 'true',
 };
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   UpdatedValues(updates) {
     const { partial } = updates;
     updates::forEachEntry(entry => {
@@ -51,7 +50,7 @@ export function decodeValue(raw) {
   try {
     if (handle) val = handle(val);
   } catch (e) {
-    if (process.env.DEBUG) log('warn', 'GM_getValue', e);
+    if (process.env.DEBUG) log('warn', ['GM_getValue'], e);
   }
   return val;
 }

+ 265 - 259
src/injected/web/gm-wrapper.js

@@ -1,43 +1,38 @@
+import { isFunction } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
 import bridge from './bridge';
-import { makeGmApi, vmOwnFunc } from './gm-api';
+import { makeGmApi } from './gm-api';
+import { FastLookup, makeComponentUtils, vmOwnFunc } from './util-web';
+import { createNullObj, safePush } from '../util';
 
-const {
-  Proxy,
-  Set, // 2x-3x faster lookup than object::has
-  Symbol: { toStringTag, iterator: iterSym },
-  Map: { [Prototype]: { get: mapGet, has: mapHas, [iterSym]: mapIter } },
-  Set: { [Prototype]: { delete: setDelete, has: setHas, [iterSym]: setIter } },
-  Object: { getOwnPropertyNames, getOwnPropertySymbols },
-} = global;
-const { concat, slice: arraySlice } = [];
-const { startsWith } = '';
 /** Name in Greasemonkey4 -> name in GM */
 const GM4_ALIAS = {
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   getResourceUrl: 'getResourceURL',
   xmlHttpRequest: 'xmlhttpRequest',
 };
-const GM4_ASYNC = [
-  'getResourceUrl',
-  'getValue',
-  'deleteValue',
-  'setValue',
-  'listValues',
-];
-const IS_TOP = window.top === window;
+const GM4_ASYNC = {
+  __proto__: null,
+  getResourceUrl: 1,
+  getValue: 1,
+  deleteValue: 1,
+  setValue: 1,
+  listValues: 1,
+};
 let gmApi;
 let componentUtils;
 
-export function wrapGM(script) {
+export function makeGmApiWrapper(script) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
-  const grant = script.meta.grant || [];
+  const { meta } = script;
+  const grant = meta.grant || [];
   if (grant.length === 1 && grant[0] === 'none') {
     grant.length = 0;
   }
-  const id = script.props.id;
-  const resources = script.meta.resources || createNullObj();
+  const { id } = script.props;
+  const resources = meta.resources || createNullObj();
+  /** @namespace VMInjectedScriptContext */
   const context = {
     id,
     script,
@@ -48,7 +43,7 @@ export function wrapGM(script) {
   };
   const gmInfo = makeGmInfo(script, resources);
   const gm = {
-    __proto__: null, // Object.create(null) may be spoofed
+    __proto__: null,
     GM: {
       __proto__: null,
       info: gmInfo,
@@ -59,21 +54,22 @@ export function wrapGM(script) {
   if (!componentUtils) {
     componentUtils = makeComponentUtils();
   }
-  // not using ...spread as it calls Babel's polyfill that calls unsafe Object.xxx
   assign(gm, componentUtils);
-  if (grant::includes('window.close')) {
+  if (grant::indexOf('window.close') >= 0) {
     gm.close = vmOwnFunc(() => bridge.post('TabClose', 0, context));
   }
-  if (grant::includes('window.focus')) {
+  if (grant::indexOf('window.focus') >= 0) {
     gm.focus = vmOwnFunc(() => bridge.post('TabFocus', 0, context));
   }
   if (!gmApi && grant.length) gmApi = makeGmApi();
   grant::forEach((name) => {
-    const gm4name = name::startsWith('GM.') && name::slice(3);
+    // Spoofed String index getters won't be called within length, length itself is unforgeable
+    const gm4name = name.length > 3 && name[2] === '.' && name[0] === 'G' && name[1] === 'M'
+      && name::slice(3);
     const fn = gmApi[gm4name ? `GM_${GM4_ALIAS[gm4name] || gm4name}` : name];
     if (fn) {
       if (gm4name) {
-        gm.GM[gm4name] = makeGmMethodCaller(fn, context, GM4_ASYNC::includes(gm4name));
+        gm.GM[gm4name] = makeGmMethodCaller(fn, context, GM4_ASYNC[gm4name]);
       } else {
         gm[name] = makeGmMethodCaller(fn, context);
       }
@@ -83,19 +79,21 @@ export function wrapGM(script) {
 }
 
 function makeGmInfo(script, resources) {
+  // TODO: move into background.js
   const { meta } = script;
-  const metaCopy = {};
+  const metaCopy = createNullObj();
+  let val;
   objectKeys(meta)::forEach((key) => {
-    let val = meta[key];
+    val = meta[key];
     switch (key) {
     case 'match': // -> matches
     case 'excludeMatch': // -> excludeMatches
       key += 'e';
-    // fallthrough
+      // fallthrough
     case 'exclude': // -> excludes
     case 'include': // -> includes
       key += 's';
-      val = val::arraySlice(); // not using [...val] as it can be broken via Array#Symbol.iterator
+      val = []::concat(val);
       break;
     default:
     }
@@ -110,10 +108,11 @@ function makeGmInfo(script, resources) {
   ]::forEach((key) => {
     if (!metaCopy[key]) metaCopy[key] = '';
   });
-  metaCopy.resources = objectKeys(resources)::map(name => ({
-    name,
-    url: resources[name],
-  }));
+  val = objectKeys(resources);
+  val::forEach((name, i) => {
+    val[i] = { name, url: resources[name] };
+  });
+  metaCopy.resources = val;
   metaCopy.unwrap = false; // deprecated, always `false`
   return {
     uuid: script.props.uuid,
@@ -136,296 +135,303 @@ function makeGmMethodCaller(gmMethod, context, isAsync) {
   );
 }
 
-const globalKeys = getOwnPropertyNames(window).filter(key => !isFrameIndex(key, true));
-/* Chrome and FF page mode: `global` is `window`
-   FF content mode: `global` is different, some props e.g. `isFinite` are defined only there */
-if (global !== window) {
-  const set = new Set(globalKeys);
-  getOwnPropertyNames(global).forEach(key => {
-    if (!isFrameIndex(key) && !set.has(key)) {
-      globalKeys.push(key);
+const globalKeysSet = FastLookup();
+const globalKeys = (function makeGlobalKeys() {
+  const kWrappedJSObject = 'wrappedJSObject';
+  const names = getOwnPropertyNames(window);
+  // True if `names` is usable as is, but FF is bugged: its names have duplicates
+  let ok = !IS_FIREFOX;
+  names::forEach(key => {
+    if (isFrameIndex(key, true)) {
+      ok = false;
+    } else {
+      globalKeysSet.add(key);
     }
   });
-}
-// FF doesn't expose wrappedJSObject as own property so we add it explicitly
-if (global.wrappedJSObject) {
-  globalKeys.push('wrappedJSObject');
-}
-const inheritedKeys = new Set([
-  ...getOwnPropertyNames(EventTarget[Prototype]),
-  ...getOwnPropertyNames(Object[Prototype]),
-]);
-inheritedKeys.has = setHas;
-
+  /* Chrome and FF page mode: `global` is `window`
+     FF content mode: `global` is different, some props e.g. `isFinite` are defined only there */
+  if (global !== window) {
+    getOwnPropertyNames(global)::forEach(key => {
+      if (!isFrameIndex(key, true)) {
+        globalKeysSet.add(key);
+        ok = false;
+      }
+    });
+  }
+  // wrappedJSObject is not included in getOwnPropertyNames so we add it explicitly.
+  if (IS_FIREFOX
+    && bridge.mode === INJECT_CONTENT
+    && kWrappedJSObject in global
+    && !globalKeysSet.has(kWrappedJSObject)) {
+    globalKeysSet.add(kWrappedJSObject);
+    if (ok) names::safePush(kWrappedJSObject);
+  }
+  return ok ? names : globalKeysSet.toArray();
+}());
+const inheritedKeys = createNullObj();
 /* These can be redefined but can't be assigned, see sandbox-globals.html */
-const readonlyKeys = [
-  'applicationCache',
-  'caches',
-  'closed',
-  'crossOriginIsolated',
-  'crypto',
-  'customElements',
-  'frameElement',
-  'history',
-  'indexedDB',
-  'isSecureContext',
-  'localStorage',
-  'mozInnerScreenX',
-  'mozInnerScreenY',
-  'navigator',
-  'sessionStorage',
-  'speechSynthesis',
-  'styleMedia',
-  'trustedTypes',
-].filter(key => key in global); // not using global[key] as some of these (caches) may throw
-
+const readonlyKeys = {
+  __proto__: null,
+  applicationCache: 1,
+  caches: 1,
+  closed: 1,
+  crossOriginIsolated: 1,
+  crypto: 1,
+  customElements: 1,
+  frameElement: 1,
+  history: 1,
+  indexedDB: 1,
+  isSecureContext: 1,
+  localStorage: 1,
+  mozInnerScreenX: 1,
+  mozInnerScreenY: 1,
+  navigator: 1,
+  sessionStorage: 1,
+  speechSynthesis: 1,
+  styleMedia: 1,
+  trustedTypes: 1,
+};
 /* These can't be redefined, see sandbox-globals.html */
-const unforgeables = new Map([
-  'Infinity',
-  'NaN',
-  'document',
-  'location',
-  'top',
-  'undefined',
-  'window',
-].map(name => {
+const unforgeables = {
+  __proto__: null,
+  Infinity: 1,
+  NaN: 1,
+  document: 1,
+  location: 1,
+  top: 1,
+  undefined: 1,
+  window: 1,
+};
+/* ~50 methods like alert/fetch/moveBy that need `window` as `this`, see sandbox-globals.html */
+const MAYBE = vmOwnFunc; // something that can't be imitated by the page
+const boundMethods = {
+  __proto__: null,
+  addEventListener: MAYBE,
+  alert: MAYBE,
+  atobSafe: MAYBE,
+  blur: MAYBE,
+  btoa: MAYBE,
+  cancelAnimationFrame: MAYBE,
+  cancelIdleCallback: MAYBE,
+  captureEvents: MAYBE,
+  clearInterval: MAYBE,
+  clearTimeout: MAYBE,
+  close: MAYBE,
+  confirm: MAYBE,
+  createImageBitmap: MAYBE,
+  dispatchEvent: MAYBE,
+  dump: MAYBE,
+  fetch: MAYBE,
+  find: MAYBE,
+  focus: MAYBE,
+  getComputedStyle: MAYBE,
+  getDefaultComputedStyle: MAYBE,
+  getSelection: MAYBE,
+  matchMedia: MAYBE,
+  moveBy: MAYBE,
+  moveTo: MAYBE,
+  open: MAYBE,
+  openDatabase: MAYBE,
+  postMessage: MAYBE,
+  print: MAYBE,
+  prompt: MAYBE,
+  queueMicrotask: MAYBE,
+  releaseEvents: MAYBE,
+  removeEventListener: MAYBE,
+  requestAnimationFrame: MAYBE,
+  requestIdleCallback: MAYBE,
+  resizeBy: MAYBE,
+  resizeTo: MAYBE,
+  scroll: MAYBE,
+  scrollBy: MAYBE,
+  scrollByLines: MAYBE,
+  scrollByPages: MAYBE,
+  scrollTo: MAYBE,
+  setInterval: MAYBE,
+  setResizable: MAYBE,
+  setTimeout: MAYBE,
+  sizeToContent: MAYBE,
+  stop: MAYBE,
+  updateCommands: MAYBE,
+  webkitCancelAnimationFrame: MAYBE,
+  webkitRequestAnimationFrame: MAYBE,
+  webkitRequestFileSystem: MAYBE,
+  webkitResolveLocalFileSystemURL: MAYBE,
+};
+
+for (const name in unforgeables) { /* proto is null */// eslint-disable-line guard-for-in
   let thisObj;
   const info = (
     describeProperty(thisObj = global, name)
     || describeProperty(thisObj = window, name)
   );
   if (info) {
-    // currently only `document`
+    // currently only `document` and `window`
     if (info.get) info.get = info.get::bind(thisObj);
     // currently only `location`
     if (info.set) info.set = info.set::bind(thisObj);
+    unforgeables[name] = info;
+  } else {
+    delete unforgeables[name];
   }
-  return info && [name, info];
-}).filter(Boolean));
-unforgeables.has = mapHas;
-unforgeables[iterSym] = mapIter;
-
-/* ~50 methods like alert/fetch/moveBy that need `window` as `this`, see sandbox-globals.html */
-const boundMethods = new Map([
-  'addEventListener',
-  'alert',
-  'atob',
-  'blur',
-  'btoa',
-  'cancelAnimationFrame',
-  'cancelIdleCallback',
-  'captureEvents',
-  'clearInterval',
-  'clearTimeout',
-  'close',
-  'confirm',
-  'createImageBitmap',
-  'dispatchEvent',
-  'dump',
-  'fetch',
-  'find',
-  'focus',
-  'getComputedStyle',
-  'getDefaultComputedStyle',
-  'getSelection',
-  'matchMedia',
-  'moveBy',
-  'moveTo',
-  'open',
-  'openDatabase',
-  'postMessage',
-  'print',
-  'prompt',
-  'queueMicrotask',
-  'releaseEvents',
-  'removeEventListener',
-  'requestAnimationFrame',
-  'requestIdleCallback',
-  'resizeBy',
-  'resizeTo',
-  'scroll',
-  'scrollBy',
-  'scrollByLines',
-  'scrollByPages',
-  'scrollTo',
-  'setInterval',
-  'setResizable',
-  'setTimeout',
-  'sizeToContent',
-  'stop',
-  'updateCommands',
-  'webkitCancelAnimationFrame',
-  'webkitRequestAnimationFrame',
-  'webkitRequestFileSystem',
-  'webkitResolveLocalFileSystemURL',
-]
-.map((key) => {
-  const value = global[key];
-  return typeof value === 'function' && [key, value::bind(global)];
-})
-.filter(Boolean));
-boundMethods.get = mapGet;
+}
+[EventTarget, Object]::forEach(src => {
+  getOwnPropertyNames(src[PROTO])::forEach(key => {
+    inheritedKeys[key] = 1;
+  });
+});
 
 /**
  * @desc Wrap helpers to prevent unexpected modifications.
  */
-function makeGlobalWrapper(local) {
+export function makeGlobalWrapper(local) {
   const events = createNullObj();
-  const scopeSym = Symbol.unscopables;
-  const globals = new Set(globalKeys);
-  globals[iterSym] = setIter;
-  globals.delete = setDelete;
-  globals.has = setHas;
-  const readonlys = new Set(readonlyKeys);
-  readonlys.delete = setDelete;
-  readonlys.has = setHas;
+  const readonlys = assign(createNullObj(), readonlyKeys);
+  let globals = globalKeysSet; // will be copied only if modified
   /* Browsers may return [object Object] for Object.prototype.toString(window)
      on our `window` proxy so jQuery libs see it as a plain object and throw
      when trying to clone its recursive properties like `self` and `window`. */
   defineProperty(local, toStringTag, { get: () => 'Window' });
-  const wrapper = new Proxy(local, {
+  const wrapper = new ProxySafe(local, {
     defineProperty(_, name, desc) {
       const isString = typeof name === 'string';
       if (!isFrameIndex(name, isString)) {
         defineProperty(local, name, desc);
-        if (isString) maybeSetEventHandler(name);
-        readonlys.delete(name);
+        if (isString) setEventHandler(name);
+        delete readonlys[name];
       }
       return true;
     },
     deleteProperty(_, name) {
-      if (!unforgeables.has(name) && delete local[name]) {
-        globals.delete(name);
+      if (!(name in unforgeables) && delete local[name]) {
+        if (globals.has(name)) {
+          if (globals === globalKeysSet) {
+            globals = globalKeysSet.clone();
+          }
+          globals.delete(name);
+        }
         return true;
       }
     },
-    get(_, name) {
-      if (name !== 'undefined' && name !== scopeSym) {
-        const value = local[name];
-        return value !== undefined || local::hasOwnProperty(name)
-          ? value
-          : resolveProp(name);
-      }
-    },
+    // Reducing "steppability" so it doesn't get in the way of debugging other parts of our code.
+    // eslint-disable-next-line no-return-assign, no-nested-ternary
+    get: (_, name) => (name === 'undefined' || name === scopeSym ? undefined
+      : (_ = local[name]) !== undefined || name in local ? _
+        : resolveProp(name, wrapper, globals, local)
+    ),
     getOwnPropertyDescriptor(_, name) {
       const ownDesc = describeProperty(local, name);
       const desc = ownDesc || globals.has(name) && describeProperty(global, name);
       if (!desc) return;
-      if (desc.value === window) desc.value = wrapper;
-      // preventing spec violation by duplicating ~10 props like NaN, Infinity, etc.
+      if (desc.value === window) {
+        desc.value = wrapper;
+      }
+      // preventing spec violation - we must mirror an unknown unforgeable prop
       if (!ownDesc && !desc.configurable) {
         const { get } = desc;
-        if (typeof get === 'function') {
-          desc.get = get::bind(global);
-        }
-        defineProperty(local, name, mapWindow(desc));
+        if (get) desc.get = get::bind(global);
+        defineProperty(local, name, desc);
       }
       return desc;
     },
-    has(_, name) {
-      return name === 'undefined' || local::hasOwnProperty(name) || globals.has(name);
-    },
-    ownKeys() {
-      return [...globals]::concat(
-        // using ::concat since array spreading can be broken via Array.prototype[Symbol.iterator]
-        getOwnPropertyNames(local)::filter(notIncludedIn, globals),
-        getOwnPropertySymbols(local)::filter(notIncludedIn, globals),
-      );
-    },
+    has: (_, name) => name === 'undefined' || name in local || globals.has(name),
+    ownKeys: () => makeOwnKeys(local, globals),
     preventExtensions() {},
     set(_, name, value) {
       const isString = typeof name === 'string';
-      if (!readonlys.has(name) && !isFrameIndex(name, isString)) {
+      let readonly = readonlys[name];
+      if (readonly === 1) {
+        readonly = globals.has(name) ? 2 : 0;
+        readonlys[name] = readonly;
+      }
+      if (!readonly && !isFrameIndex(name, isString)) {
         local[name] = value;
-        if (isString) maybeSetEventHandler(name, value);
+        if (isString) setEventHandler(name, value, globals, events);
       }
       return true;
     },
   });
-  unforgeables::forEach(entry => {
-    const name = entry[0];
-    const desc = entry[1];
+  for (const name in unforgeables) { /* proto is null */// eslint-disable-line guard-for-in
+    const desc = unforgeables[name];
     if (name === 'window' || name === 'top' && IS_TOP) {
       delete desc.get;
       delete desc.set;
       desc.value = wrapper;
     }
-    defineProperty(local, name, mapWindow(desc));
-  });
-  function mapWindow(desc) {
-    if (desc && desc.value === window) {
-      desc = assign({}, desc);
-      desc.value = wrapper;
-    }
-    return desc;
+    defineProperty(local, name, desc);
   }
-  function resolveProp(name) {
-    let value = boundMethods.get(name);
-    const canCopy = value || inheritedKeys.has(name) || globals.has(name);
-    if (!value && (canCopy || isFrameIndex(name, typeof name === 'string'))) {
-      value = global[name];
-    }
-    if (value === window) {
-      value = wrapper;
-    }
-    if (canCopy && (
-      typeof value === 'function'
-      || typeof value === 'object' && value && name !== 'event'
-      // window.event contains the current event so it's always different
-    )) {
-      local[name] = value;
+  return wrapper;
+}
+
+function makeOwnKeys(local, globals) {
+  /** Note that arrays can be eavesdropped via prototype setters like '0','1',...
+   * on `push` and `arr[i] = 123`, as well as via getters if you read beyond
+   * its length or from an unassigned `hole`. */
+  const names = getOwnPropertyNames(local)::filter(notIncludedIn, globals);
+  const symbols = getOwnPropertySymbols(local)::filter(notIncludedIn, globals);
+  const frameIndexes = [];
+  for (let i = 0; (global[i] || 0)::objectToString() === '[object Window]'; i += 1) {
+    if (!(i in local)) {
+      frameIndexes::safePush(`${i}`);
     }
-    return value;
   }
-  function maybeSetEventHandler(name, value) {
-    if (!name::startsWith('on') || !globals.has(name)) {
-      return;
-    }
-    name = name::slice(2);
-    window::removeEventListener(name, events[name]);
-    if (typeof value === 'function') {
-      // the handler will be unique so that one script couldn't remove something global
-      // like console.log set by another script
-      window::addEventListener(name, events[name] = value::bind(window));
-    } else {
-      delete events[name];
+  return []::concat(
+    globals === globalKeysSet ? globalKeys : globals.toArray(),
+    frameIndexes,
+    names,
+    symbols,
+  );
+}
+
+function resolveProp(name, wrapper, globals, local) {
+  let value = boundMethods[name];
+  if (value === MAYBE) {
+    value = window[name];
+    if (isFunction(value)) {
+      value = value::bind(window);
     }
+    boundMethods[name] = value;
   }
-  return wrapper;
+  const canCopy = value || name in inheritedKeys || globals.has(name);
+  if (!value && (canCopy || isFrameIndex(name, typeof name === 'string'))) {
+    value = global[name];
+  }
+  if (value === window) {
+    value = wrapper;
+  }
+  if (canCopy && (
+    isFunction(value)
+    || typeof value === 'object' && value && name !== 'event'
+    // window.event contains the current event so it's always different
+  )) {
+    local[name] = value;
+  }
+  return value;
 }
 
-// Adding the polyfills in Chrome (always as it doesn't provide them)
-// and in Firefox page mode (while preserving the native ones in content mode)
-// for compatibility with many [old] scripts that use these utils blindly
-function makeComponentUtils() {
-  const source = bridge.mode === INJECT_CONTENT && global;
-  return {
-    cloneInto: source.cloneInto || vmOwnFunc(
-      (obj) => obj,
-    ),
-    createObjectIn: source.createObjectIn || vmOwnFunc(
-      (targetScope, { defineAs } = {}) => {
-        const obj = {};
-        if (defineAs) targetScope[defineAs] = obj;
-        return obj;
-      },
-    ),
-    exportFunction: source.exportFunction || vmOwnFunc(
-      (func, targetScope, { defineAs } = {}) => {
-        if (defineAs) targetScope[defineAs] = func;
-        return func;
-      },
-    ),
-  };
+function setEventHandler(name, value, globals, events) {
+  // Spoofed String index getters won't be called within length, length itself is unforgeable
+  if (name.length < 3 || name[0] !== 'o' || name[1] !== 'n' || !globals.has(name)) {
+    return;
+  }
+  name = name::slice(2);
+  window::off(name, events[name]);
+  if (isFunction(value)) {
+    // the handler will be unique so that one script couldn't remove something global
+    // like console.log set by another script
+    window::on(name, events[name] = value::bind(window));
+  } else {
+    delete events[name];
+  }
 }
 
-/* The index strings that look exactly like integers can't be forged
-   but for example '011' doesn't look like 11 so it's allowed */
+/** The index strings that look exactly like integers can't be forged
+ * but for example '011' doesn't look like 11 so it's allowed */
 function isFrameIndex(key, isString) {
   return isString && key >= 0 && key <= 0xFFFF_FFFE && key === `${+key}`;
 }
 
-/** @this {Set} */
+/** @this {FastLookup|Set} */
 function notIncludedIn(key) {
   return !this.has(key);
 }

+ 23 - 23
src/injected/web/index.js

@@ -1,19 +1,15 @@
 import { INJECT_PAGE, INJECT_CONTENT } from '#/common/consts';
-import { bindEvents } from '../utils';
-import { log, logging } from '../utils/helpers';
+import { bindEvents, createNullObj, log } from '../util';
 import bridge from './bridge';
-import { wrapGM } from './gm-wrapper';
 import store from './store';
 import './gm-values';
 import './notifications';
 import './requests';
 import './tabs';
+import { makeGmApiWrapper } from './gm-wrapper';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
-const { KeyboardEvent, MouseEvent } = global;
-const { get: getCurrentScript } = describeProperty(Document.prototype, 'currentScript');
-
 const sendSetTimeout = () => bridge.send('SetTimeout', 0);
 const resolvers = createNullObj();
 const waiters = createNullObj();
@@ -27,10 +23,10 @@ export default function initialize(
   bridge.dataKey = contentId;
   if (invokeHost) {
     bridge.mode = INJECT_CONTENT;
-    bridge.post = (cmd, data, context) => {
-      invokeHost({ cmd, data, dataKey: (context || bridge).dataKey }, INJECT_CONTENT);
+    bridge.post = (cmd, data, context, node) => {
+      invokeHost({ cmd, data, node, dataKey: (context || bridge).dataKey }, INJECT_CONTENT);
     };
-    invokeGuest = (cmd, data) => bridge.onHandle({ cmd, data });
+    invokeGuest = (cmd, data, realm, node) => bridge.onHandle({ cmd, data, node });
     global.chrome = undefined;
     global.browser = undefined;
     bridge.addHandlers({
@@ -40,6 +36,10 @@ export default function initialize(
     bridge.mode = INJECT_PAGE;
     bindEvents(webId, contentId, bridge);
     bridge.addHandlers({
+      /** @this {Node} contentDocument */
+      Frame(id) {
+        this[id] = VAULT;
+      },
       Ping() {
         bridge.post('Pong');
       },
@@ -49,29 +49,29 @@ export default function initialize(
 }
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
-  Command(data) {
-    const cmd = data[0];
-    const evt = data[1];
-    const constructor = evt.key ? KeyboardEvent : MouseEvent;
-    const fn = store.commands[cmd];
+  __proto__: null,
+  Command({ id, cap, evt }) {
+    const constructor = evt.key ? KeyboardEventSafe : MouseEventSafe;
+    const fn = store.commands[`${id}:${cap}`];
     if (fn) fn(new constructor(evt.type, evt));
   },
-  Callback({ callbackId, payload }) {
-    const fn = bridge.callbacks[callbackId];
-    if (fn) fn(payload);
+  /** @this {Node} */
+  Callback({ id, data }) {
+    const fn = bridge.callbacks[id];
+    delete bridge.callbacks[id];
+    if (fn) this::fn(data);
   },
   ScriptData({ info, items, runAt }) {
     if (info) {
-      assign(info.cache, bridge.cache);
+      info.cache = assign(createNullObj(), info.cache, bridge.cache);
       assign(bridge, info);
     }
     if (items) {
       const { stage } = items[0];
-      if (stage) waiters[stage] = new Promise(resolve => { resolvers[stage] = resolve; });
+      if (stage) waiters[stage] = new PromiseSafe(resolve => { resolvers[stage] = resolve; });
       items::forEach(createScriptData);
       // FF bug workaround to enable processing of sourceURL in injected page scripts
-      if (bridge.isFirefox && bridge.mode === INJECT_PAGE) {
+      if (IS_FIREFOX && bridge.mode === INJECT_PAGE) {
         bridge.post('InjectList', runAt);
       }
     }
@@ -88,7 +88,7 @@ bridge.addHandlers({
 
 function createScriptData(item) {
   const { dataKey } = item;
-  store.values[item.props.id] = item.values || {};
+  store.values[item.props.id] = item.values || createNullObj();
   if (window[dataKey]) { // executeScript ran before GetInjected response
     onCodeSet(item, window[dataKey]);
   } else {
@@ -108,7 +108,7 @@ async function onCodeSet(item, fn) {
   }
   const run = () => {
     bridge.post('Run', item.props.id, item);
-    wrapGM(item)::fn(logging.error);
+    makeGmApiWrapper(item)::fn(logging.error);
   };
   const el = document::getCurrentScript();
   const wait = waiters[stage];

+ 3 - 2
src/injected/web/notifications.js

@@ -1,10 +1,11 @@
 import bridge from './bridge';
+import { createNullObj } from '../util';
 
 let lastId = 0;
-const notifications = {};
+const notifications = createNullObj();
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   NotificationClicked(id) {
     const fn = notifications[id]?.onclick;
     if (fn) fn();

+ 27 - 26
src/injected/web/requests.js

@@ -1,19 +1,16 @@
-import { getUniqId } from '#/common';
-import { objectPick } from '#/common/object';
-import { log, NS_HTML } from '../utils/helpers';
+import { getUniqId, isFunction } from '#/common';
+import { NS_HTML, createNullObj, getOwnProp, log, pickIntoThis } from '../util';
 import bridge from './bridge';
 
-const idMap = {};
-
-export const { atob } = global;
-const { Blob, DOMParser, FileReader, Response } = global;
-const { parseFromString } = DOMParser[Prototype];
-const { blob: resBlob } = Response[Prototype];
-const { get: getHref } = describeProperty(HTMLAnchorElement[Prototype], 'href');
-const { readAsDataURL } = FileReader[Prototype];
+const idMap = createNullObj();
+const { DOMParser, FileReader, Response } = global;
+const { parseFromString } = DOMParser[PROTO];
+const { blob: resBlob } = Response[PROTO];
+const { get: getHref } = describeProperty(HTMLAnchorElement[PROTO], 'href');
+const { readAsDataURL } = FileReader[PROTO];
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   HttpRequested(msg) {
     const req = idMap[msg.id];
     if (req) callback(req, msg);
@@ -21,10 +18,11 @@ bridge.addHandlers({
 });
 
 export function onRequestCreate(opts, context) {
-  if (!opts.url) throw new Error('Required parameter "url" is missing.');
+  if (!opts.url) throw new ErrorSafe('Required parameter "url" is missing.');
   const scriptId = context.id;
   const id = getUniqId(`VMxhr${scriptId}`);
   const req = {
+    __proto__: null,
     id,
     scriptId,
     opts,
@@ -50,16 +48,17 @@ function parseData(req, msg) {
     res = new DOMParser()::parseFromString(raw, type);
   } else if (msg.chunked) {
     // arraybuffer/blob in incognito tabs is transferred as ArrayBuffer encoded in string chunks
-    const arr = new Uint8Array(req.dataSize);
+    // TODO: move this block in content if the speed is the same for very big data
+    const arr = new Uint8ArraySafe(req.dataSize);
     let dstIndex = 0;
     raw::forEach((chunk) => {
-      const len = (chunk = atob(chunk)).length;
+      const len = (chunk = window::atobSafe(chunk)).length;
       for (let j = 0; j < len; j += 1, dstIndex += 1) {
         arr[dstIndex] = chunk::charCodeAt(j);
       }
     });
     res = responseType === 'blob'
-      ? new Blob([arr], { type: msg.contentType })
+      ? new BlobSafe([arr], { type: msg.contentType })
       : arr.buffer;
   } else {
     // text, blob, arraybuffer
@@ -107,7 +106,8 @@ async function callback(req, msg) {
       },
     });
     if (headers != null) req.headers = headers;
-    if (text != null) req.text = text[0] === 'same' ? response : text;
+    // Spoofed String/Array index getters won't be called within length, length itself is unforgeable
+    if (text != null) req.text = text.length && text[0] === 'same' ? response : text;
     data.context = opts.context;
     data.responseHeaders = req.headers;
     data.responseText = req.text;
@@ -121,7 +121,7 @@ function receiveAllChunks(req, response, { dataSize, numChunks }) {
   req.dataSize = dataSize;
   if (numChunks > 1) {
     req.chunks = res;
-    req.chunksPromise = new Promise(resolve => {
+    req.chunksPromise = new PromiseSafe(resolve => {
       req.resolve = resolve;
     });
     res = req.chunksPromise;
@@ -146,7 +146,8 @@ async function start(req, context) {
   // it's true by default per the standard/historical behavior of gmxhr
   const { data, withCredentials = true, anonymous = !withCredentials } = opts;
   idMap[id] = req;
-  bridge.post('HttpRequest', assign({
+  bridge.post('HttpRequest', {
+    __proto__: null,
     id,
     scriptId,
     anonymous,
@@ -154,7 +155,7 @@ async function start(req, context) {
       // `binary` is for TM/GM-compatibility + non-objects = must use a string `data`
       || (opts.binary || typeof data !== 'object') && [`${data}`]
       // FF56+ can send any cloneable data directly, FF52-55 can't due to https://bugzil.la/1371246
-      || (bridge.isFirefox >= 56) && [data]
+      || IS_FIREFOX && bridge.ua.browserVersion >= 56 && [data]
       // TODO: support huge data by splitting it to multiple messages
       || await encodeBody(data),
     eventsToNotify: [
@@ -166,18 +167,18 @@ async function start(req, context) {
       'progress',
       'readystatechange',
       'timeout',
-    ]::filter(e => typeof opts[`on${e}`] === 'function'),
+    ]::filter(key => isFunction(getOwnProp(opts, `on${key}`))),
     responseType: getResponseType(opts),
     url: getFullUrl(opts.url),
     wantsBlob: opts.responseType === 'blob',
-  }, objectPick(opts, [
+  }::pickIntoThis(opts, [
     'headers',
     'method',
     'overrideMimeType',
     'password',
     'timeout',
     'user',
-  ])), context);
+  ]), context);
 }
 
 function getFullUrl(url) {
@@ -205,11 +206,11 @@ function getResponseType({ responseType = '' }) {
 
 /** Polyfill for Chrome's inability to send complex types over extension messaging */
 async function encodeBody(body) {
-  const wasBlob = body instanceof Blob;
+  const wasBlob = body::objectToString() === '[object Blob]';
   const blob = wasBlob ? body : await new Response(body)::resBlob();
   const reader = new FileReader();
-  return new Promise((resolve) => {
-    reader::addEventListener('load', () => resolve([
+  return new PromiseSafe((resolve) => {
+    reader::on('load', () => resolve([
       reader.result,
       blob.type,
       wasBlob,

+ 150 - 0
src/injected/web/safe-globals-web.js

@@ -0,0 +1,150 @@
+/* eslint-disable camelcase, one-var, one-var-declaration-per-line, no-unused-vars,
+   prefer-const, import/no-mutable-exports */
+
+/**
+ * `safeCall` is used by our modified babel-plugin-safe-bind.js.
+ * `export` is stripped in the final output and is only used for our NodeJS test scripts.
+ * WARNING! Don't use modern JS syntax like ?. or ?? as this file isn't preprocessed by Babel.
+ */
+
+export let
+  // window
+  BlobSafe,
+  CustomEventSafe,
+  ErrorSafe,
+  KeyboardEventSafe,
+  MouseEventSafe,
+  Object,
+  PromiseSafe,
+  ProxySafe,
+  Uint8ArraySafe,
+  atobSafe,
+  fire,
+  off,
+  on,
+  // Symbol
+  scopeSym,
+  toStringTag,
+  // Object
+  apply,
+  assign,
+  bind,
+  defineProperty,
+  describeProperty,
+  getOwnPropertyNames,
+  getOwnPropertySymbols,
+  objectKeys,
+  objectValues,
+  // Object.prototype
+  hasOwnProperty,
+  objectToString,
+  /** Array.prototype can be eavesdropped via setters like '0','1',...
+   * on `push` and `arr[i] = 123`, as well as via getters if you read beyond
+   * its length or from an unassigned `hole`. */
+  concat,
+  filter,
+  forEach,
+  indexOf,
+  map,
+  // Element.prototype
+  remove,
+  setAttribute,
+  // String.prototype
+  charCodeAt,
+  slice,
+  replace,
+  // Set.prototype
+  setDelete,
+  /** @type {Set.prototype.forEach} */
+  setForEach,
+  setHas,
+  // document
+  createElementNS,
+  // various methods
+  safeCall,
+  jsonParse,
+  regexpTest,
+  then,
+  logging,
+  // various getters
+  getDetail,
+  getRelatedTarget,
+  getCurrentScript;
+
+/**
+ * VAULT consists of the parent's safe globals to protect our communications/globals
+ * from a page that creates an iframe with src = location and modifies its contents
+ * immediately after adding it to DOM via direct manipulation in frame.contentWindow
+ * or window[0] before our content script runs at document_start, https://crbug.com/1261964 */
+export const VAULT = (() => {
+  let ArrayP;
+  let ElementP;
+  let StringP;
+  let i = -1;
+  let res;
+  if (process.env.VAULT_ID) {
+    res = document[process.env.VAULT_ID];
+    delete document[process.env.VAULT_ID];
+  }
+  if (!res) {
+    res = { __proto__: null };
+  }
+  res = [
+    // window
+    BlobSafe = res[i += 1] || window.Blob,
+    CustomEventSafe = res[i += 1] || window.CustomEvent,
+    ErrorSafe = res[i += 1] || window.Error,
+    KeyboardEventSafe = res[i += 1] || window.KeyboardEvent,
+    MouseEventSafe = res[i += 1] || window.MouseEvent,
+    Object = res[i += 1] || window.Object, // minification and guarding webpack Object(import) calls
+    PromiseSafe = res[i += 1] || window.Promise,
+    ProxySafe = res[i += 1] || global.Proxy, // In FF content mode it's not equal to window.Proxy
+    Uint8ArraySafe = res[i += 1] || window.Uint8Array,
+    atobSafe = res[i += 1] || window.atob,
+    fire = res[i += 1] || window.dispatchEvent,
+    off = res[i += 1] || window.removeEventListener,
+    on = res[i += 1] || window.addEventListener,
+    // Symbol
+    scopeSym = res[i += 1] || Symbol.unscopables,
+    toStringTag = res[i += 1] || Symbol.toStringTag,
+    // Object
+    describeProperty = res[i += 1] || Object.getOwnPropertyDescriptor,
+    defineProperty = res[i += 1] || Object.defineProperty,
+    getOwnPropertyNames = res[i += 1] || Object.getOwnPropertyNames,
+    getOwnPropertySymbols = res[i += 1] || Object.getOwnPropertySymbols,
+    assign = res[i += 1] || Object.assign,
+    objectKeys = res[i += 1] || Object.keys,
+    objectValues = res[i += 1] || Object.values,
+    apply = res[i += 1] || Object.apply,
+    bind = res[i += 1] || Object.bind,
+    // Object.prototype
+    hasOwnProperty = res[i += 1] || Object[PROTO].hasOwnProperty,
+    objectToString = res[i += 1] || Object[PROTO].toString,
+    // Array.prototype
+    concat = res[i += 1] || (ArrayP = Array[PROTO]).concat,
+    filter = res[i += 1] || ArrayP.filter,
+    forEach = res[i += 1] || ArrayP.forEach,
+    indexOf = res[i += 1] || ArrayP.indexOf,
+    map = res[i += 1] || ArrayP.map,
+    // Element.prototype
+    remove = res[i += 1] || (ElementP = Element[PROTO]).remove,
+    setAttribute = res[i += 1] || ElementP.setAttribute,
+    // String.prototype
+    charCodeAt = res[i += 1] || (StringP = String[PROTO]).charCodeAt,
+    slice = res[i += 1] || StringP.slice,
+    replace = res[i += 1] || StringP.replace,
+    // document
+    createElementNS = res[i += 1] || document.createElementNS,
+    // various methods
+    safeCall = res[i += 1] || Object.call.bind(Object.call),
+    jsonParse = res[i += 1] || JSON.parse,
+    regexpTest = res[i += 1] || RegExp[PROTO].test,
+    then = res[i += 1] || PromiseSafe[PROTO].then,
+    logging = res[i += 1] || assign({ __proto__: null }, console),
+    // various getters
+    getDetail = res[i += 1] || describeProperty(CustomEventSafe[PROTO], 'detail').get,
+    getRelatedTarget = res[i += 1] || describeProperty(MouseEventSafe[PROTO], 'relatedTarget').get,
+    getCurrentScript = res[i += 1] || describeProperty(Document[PROTO], 'currentScript').get,
+  ];
+  return res;
+})();

+ 3 - 1
src/injected/web/store.js

@@ -1,5 +1,7 @@
+import { createNullObj } from '../util';
+
 export default {
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   commands: createNullObj(),
   values: createNullObj(),
   state: 0,

+ 2 - 1
src/injected/web/tabs.js

@@ -1,10 +1,11 @@
 import bridge from './bridge';
+import { createNullObj } from '../util';
 
 let lastId = 0;
 const tabs = createNullObj();
 
 bridge.addHandlers({
-  __proto__: null, // Object.create(null) may be spoofed
+  __proto__: null,
   TabClosed(key) {
     const item = tabs[key];
     if (item) {

+ 133 - 0
src/injected/web/util-web.js

@@ -0,0 +1,133 @@
+import { createNullObj } from '../util';
+import bridge from '#/injected/web/bridge';
+import { INJECT_CONTENT } from '#/common/consts';
+
+const vmOwnFuncToString = () => '[Violentmonkey property]';
+// Firefox defines `isFinite` on `global` not on `window`
+const { isFinite } = global; // eslint-disable-line no-restricted-properties
+const { toString: numberToString } = 0;
+/**
+ * Using duck typing for #565 steamcommunity.com has overridden `Array.prototype`
+ * If prototype is modified Object.prototype.toString.call(obj) won't give '[object Array]'
+ */
+const isArray = obj => obj
+  && typeof obj.length === 'number'
+  && typeof obj.splice === 'function';
+// Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON#Polyfill
+const escMap = {
+  '"': '\\"',
+  '\\': '\\\\',
+  '\b': '\\b',
+  '\f': '\\f',
+  '\n': '\\n',
+  '\r': '\\r',
+  '\t': '\\t',
+};
+const escRE = /[\\"\u0000-\u001F\u2028\u2029]/g; // eslint-disable-line no-control-regex
+const escFunc = m => escMap[m] || `\\u${(m::charCodeAt(0) + 0x10000)::numberToString(16)::slice(1)}`;
+/**
+ * When running in the page context we must beware of sites that override Array#toJSON
+ * leading to an invalid result, which is why our jsonDump() ignores toJSON.
+ * Thus, we use the native JSON.stringify() only in the content script context and only until
+ * a userscript is injected into this context (due to `@inject-into` and/or a CSP problem).
+ */
+export const jsonDump = value => {
+  if (value == null) return 'null';
+  let res;
+  switch (typeof value) {
+  case 'bigint':
+  case 'number':
+    res = isFinite(value) ? `${value}` : 'null';
+    break;
+  case 'boolean':
+    res = `${value}`;
+    break;
+  case 'string':
+    res = `"${value::replace(escRE, escFunc)}"`;
+    break;
+  case 'object':
+    if (isArray(value)) {
+      res = '[';
+      value::forEach(v => { res += `${res.length > 1 ? ',' : ''}${jsonDump(v) ?? 'null'}`; });
+      res += ']';
+    } else {
+      res = '{';
+      objectKeys(value)::forEach(key => {
+        const v = jsonDump(value[key]);
+        // JSON.stringify skips keys with `undefined` or incompatible values
+        if (v !== undefined) {
+          res += `${res.length > 1 ? ',' : ''}${jsonDump(key)}:${v}`;
+        }
+      });
+      res += '}';
+    }
+    break;
+  default:
+  }
+  return res;
+};
+
+/**
+ * 2x faster than `Set`, 5x faster than flat object
+ * @param {Object} [hubs]
+ */
+export const FastLookup = (hubs = createNullObj()) => {
+  /** @namespace FastLookup */
+  return {
+    add(val) {
+      getHub(val, true)[val] = true;
+    },
+    clone() {
+      const clone = createNullObj();
+      for (const group in hubs) { /* proto is null */// eslint-disable-line guard-for-in
+        clone[group] = assign(createNullObj(), hubs[group]);
+      }
+      return FastLookup(clone);
+    },
+    delete(val) {
+      delete getHub(val)?.[val];
+    },
+    has: val => getHub(val)?.[val],
+    toArray: () => concat::apply([], objectValues(hubs)::map(objectKeys)),
+  };
+  function getHub(val, autoCreate) {
+    const group = val.length ? val[0] : ''; // length is unforgeable, index getters aren't
+    const hub = hubs[group] || (
+      autoCreate ? (hubs[group] = createNullObj())
+        : null
+    );
+    return hub;
+  }
+};
+
+export const vmOwnFunc = (func, toString) => {
+  defineProperty(func, 'toString', { value: toString || vmOwnFuncToString });
+  return func;
+};
+
+/**
+ * Adding the polyfills in Chrome (always as it doesn't provide them)
+ * and in Firefox page mode (while preserving the native ones in content mode)
+ * for compatibility with many [old] scripts that use these utils blindly
+ */
+export const makeComponentUtils = () => {
+  const {
+    cloneInto = obj => obj,
+    createObjectIn = (targetScope, { defineAs } = {}) => {
+      const obj = {};
+      if (defineAs) targetScope[defineAs] = obj;
+      return obj;
+    },
+    exportFunction = (func, targetScope, { defineAs } = {}) => {
+      if (defineAs) targetScope[defineAs] = func;
+      return func;
+    },
+  } = IS_FIREFOX && bridge.mode === INJECT_CONTENT
+    ? global
+    : createNullObj();
+  return {
+    cloneInto,
+    createObjectIn,
+    exportFunction,
+  };
+};

+ 6 - 5
src/popup/views/app.vue

@@ -139,7 +139,7 @@
               v-for="(cap, i) in store.commands[item.data.props.id]"
               :key="i"
               :tabIndex="tabIndex"
-              :data-cmd="`${item.data.props.id}:${cap}`"
+              :CMD.prop="{ id: item.data.props.id, cap }"
               :data-message="cap"
               @mousedown="onCommand"
               @mouseup="onCommand"
@@ -363,10 +363,11 @@ export default {
         mousedownElement = el;
         evt.preventDefault();
       } else if (type === 'keydown' || mousedownElement === el) {
-        sendTabCmd(store.currentTab.id, 'Command', [
-          el.dataset.cmd,
-          objectPick(evt, ['type', 'button', 'shiftKey', 'altKey', 'ctrlKey', 'metaKey', 'key', 'keyCode', 'code']),
-        ]);
+        sendTabCmd(store.currentTab.id, 'Command', {
+          ...el.CMD,
+          evt: objectPick(evt, ['type', 'button', 'shiftKey', 'altKey', 'ctrlKey', 'metaKey',
+            'key', 'keyCode', 'code']),
+        });
         window.close();
       }
     },

+ 2 - 2
test/injected/gm-resource.test.js

@@ -1,6 +1,6 @@
 import test from 'tape';
 import { buffer2string } from '#/common';
-import { wrapGM } from '#/injected/web/gm-wrapper';
+import { makeGmApiWrapper } from '#/injected/web/gm-wrapper';
 import bridge from '#/injected/web/bridge';
 
 const stringAsBase64 = str => btoa(buffer2string(new TextEncoder().encode(str).buffer));
@@ -30,7 +30,7 @@ const script = {
     },
   },
 };
-const wrapper = wrapGM(script);
+const wrapper = makeGmApiWrapper(script);
 bridge.cache = {
   [script.meta.resources.foo]: `text/plain,${stringAsBase64(RESOURCE_TEXT)}`,
 };

+ 1 - 1
test/injected/helpers.test.js

@@ -1,5 +1,5 @@
 import test from 'tape';
-import { jsonDump } from '#/injected/utils/helpers';
+import { jsonDump } from '#/injected/web/util-web';
 
 test('jsonDump', (t) => {
   // eslint-disable-next-line no-restricted-syntax

+ 8 - 7
test/mock/polyfill.js

@@ -1,8 +1,8 @@
 import tldRules from 'tldjs/rules.json';
 import { JSDOM } from 'jsdom';
 
-global.window = global;
-
+global.window = new JSDOM('').window;
+global.chrome = {};
 global.browser = {
   storage: {
     local: {
@@ -21,7 +21,7 @@ global.browser = {
   },
 };
 
-const domProps = Object.getOwnPropertyDescriptors(new JSDOM('').window);
+const domProps = Object.getOwnPropertyDescriptors(window);
 for (const k of Object.keys(domProps)) {
   if (k.endsWith('Storage') || k in global) delete domProps[k];
 }
@@ -38,7 +38,8 @@ global.URL = {
   },
 };
 
-const globalsCommon = require('#/common/safe-globals');
-const globalsInjected = require('#/injected/safe-injected-globals');
-
-Object.assign(global, globalsCommon, globalsInjected);
+global.__VAULT_ID__ = false;
+Object.assign(global, require('#/common/safe-globals'));
+Object.assign(global, require('#/injected/safe-globals-injected'));
+Object.assign(global, require('#/injected/content/safe-globals-content'));
+Object.assign(global, require('#/injected/web/safe-globals-web'));