Pārlūkot izejas kodu

fix: define safe globals via header injection (#1359)

+ using safeCall to avoid interception by web page
+ removed unsafe optional calls ?.()
tophf 4 gadi atpakaļ
vecāks
revīzija
9c94de280d

+ 1 - 1
.babelrc.js

@@ -10,6 +10,6 @@ module.exports = {
     }],
   ],
   plugins: [
-    '@babel/plugin-proposal-function-bind',
+    './scripts/babel-plugin-safe-bind.js',
   ],
 };

+ 37 - 2
.eslintrc.js

@@ -1,12 +1,21 @@
+const acorn = require('acorn');
 const unsafeEnvironment = [
   'src/injected/**/*.js',
-  // these are used by `injected`
+];
+// some functions are used by `injected`
+const unsafeSharedEnvironment = [
   'src/common/browser.js',
   'src/common/consts.js',
   'src/common/index.js',
   'src/common/object.js',
   'src/common/util.js',
 ];
+const commonGlobals = getGlobals('src/common/safe-globals.js');
+const injectedGlobals = {
+  ...commonGlobals,
+  ...getGlobals('src/injected/safe-injected-globals.js'),
+};
+
 module.exports = {
   root: true,
   extends: [
@@ -22,19 +31,28 @@ 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: unsafeEnvironment,
+    excludedFiles: [...unsafeEnvironment, ...unsafeSharedEnvironment],
     globals: {
       browser: false,
+      ...commonGlobals,
     },
   }, {
     files: unsafeEnvironment,
+    globals: injectedGlobals,
     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. */
       'no-restricted-syntax': ['error', {
         selector: 'ObjectExpression > ExperimentalSpreadProperty',
         message: 'Object spread in an unsafe environment',
+      }, {
+        selector: 'OptionalCallExpression',
+        message: 'Optional call in an unsafe environment',
       }],
     },
   }],
@@ -52,3 +70,20 @@ 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 } }) {
+      if (name) {
+        // const NAME = whatever
+        res[name] = false;
+      } else if (properties) {
+        // const { NAME1, prototype: { NAME2: ALIAS2 } } = whatever
+        properties.forEach(({ value }) => processId({ id: value }));
+      }
+    });
+  });
+  return res;
+}

+ 3 - 1
package.json

@@ -32,6 +32,8 @@
     "@gera2ld/plaid-webpack": "~1.5.5",
     "@types/chrome": "0.0.101",
     "@types/firefox-webext-browser": "82.0.0",
+    "acorn": "^8.4.1",
+    "confusing-browser-globals": "^1.0.10",
     "cross-env": "^7.0.2",
     "cross-spawn": "^7.0.1",
     "del": "^5.1.0",
@@ -79,4 +81,4 @@
     }
   },
   "beta": 5
-}
+}

+ 102 - 0
scripts/babel-plugin-safe-bind.js

@@ -0,0 +1,102 @@
+/*
+Modified the original to use a global `safeCall`:
+https://babeljs.io/docs/en/babel-plugin-proposal-function-bind
+
+MIT License
+
+Copyright (c) 2014-present Sebastian McKenzie and other contributors
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+exports.default = void 0;
+
+var _helperPluginUtils = require("@babel/helper-plugin-utils");
+
+var _pluginSyntaxFunctionBind = require("@babel/plugin-syntax-function-bind");
+
+var _core = require("@babel/core");
+
+var _default = (0, _helperPluginUtils.declare)(api => {
+  api.assertVersion(7);
+
+  function getTempId(scope) {
+    let id = scope.path.getData("functionBind");
+    if (id) return _core.types.cloneNode(id);
+    id = scope.generateDeclaredUidIdentifier("context");
+    return scope.path.setData("functionBind", id);
+  }
+
+  function getStaticContext(bind, scope) {
+    const object = bind.object || bind.callee.object;
+    return scope.isStatic(object) && (_core.types.isSuper(object) ? _core.types.thisExpression() : object);
+  }
+
+  function inferBindContext(bind, scope) {
+    const staticContext = getStaticContext(bind, scope);
+    if (staticContext) return _core.types.cloneNode(staticContext);
+    const tempId = getTempId(scope);
+
+    if (bind.object) {
+      bind.callee = _core.types.sequenceExpression([_core.types.assignmentExpression("=", tempId, bind.object), bind.callee]);
+    } else {
+      bind.callee.object = _core.types.assignmentExpression("=", tempId, bind.callee.object);
+    }
+
+    return _core.types.cloneNode(tempId);
+  }
+
+  return {
+    name: "safe-function-bind",
+    inherits: _pluginSyntaxFunctionBind.default,
+    visitor: {
+      CallExpression({
+        node,
+        scope
+      }) {
+        const bind = node.callee;
+        if (!_core.types.isBindExpression(bind)) return;
+        const context = inferBindContext(bind, scope);
+        // ORIGINAL:
+        // node.callee = _core.types.memberExpression(bind.callee, _core.types.identifier("call"));
+        // node.arguments.unshift(context);
+        // MODIFIED to use safeCall created in safe-globals.js:
+        node.callee = _core.types.identifier("safeCall");
+        node.arguments.unshift(bind.callee, context);
+      },
+
+      BindExpression(path) {
+        const {
+          node,
+          scope
+        } = path;
+        const context = inferBindContext(node, scope);
+        path.replaceWith(_core.types.callExpression(_core.types.memberExpression(node.callee, _core.types.identifier("bind")), [context]));
+      }
+
+    }
+  };
+});
+
+exports.default = _default;

+ 16 - 1
scripts/webpack.conf.js

@@ -80,14 +80,27 @@ const modify = (page, entry, init) => modifyWebpackConfig(
 // 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)`;
+const [globalsCommonHeader, globalsInjectedHeader] = [
+  './src/common/safe-globals.js',
+  './src/injected/safe-injected-globals.js',
+].map(path =>
+  require('fs').readFileSync(path, {encoding: 'utf8'}).replace(/export const/g, 'const'));
+
 module.exports = Promise.all([
   modify((config) => {
     config.output.publicPath = '/';
+    config.plugins.push(
+      new WrapperWebpackPlugin({
+        header: `{ ${globalsCommonHeader}`,
+        footer: `}`,
+        test: /^(?!injected|public).*\.js$/,
+      }));
   }),
   modify('injected', './src/injected', (config) => {
     config.plugins.push(
       new WrapperWebpackPlugin({
-        header: skipReinjectionHeader,
+        header: `${skipReinjectionHeader} { ${globalsCommonHeader};${globalsInjectedHeader}`,
+        footer: `}`,
       }));
   }),
   modify('injected-web', './src/injected/web', (config) => {
@@ -97,6 +110,8 @@ module.exports = Promise.all([
         header: `${skipReinjectionHeader}
           window['${INIT_FUNC_NAME}'] = function () {
             var module = { exports: {} };
+            ${globalsCommonHeader}
+            ${globalsInjectedHeader}
           `,
         footer: `
             var exports = module.exports;

+ 1 - 1
src/background/utils/preinject.js

@@ -1,4 +1,4 @@
-import { getScriptName, getUniqId, hasOwnProperty } from '#/common';
+import { getScriptName, getUniqId } from '#/common';
 import {
   INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECTABLE_TAB_URL_RE, METABLOCK_RE,
 } from '#/common/consts';

+ 2 - 5
src/common/object.js

@@ -2,15 +2,12 @@
    when accessed after the initial event loop task in `injected/web`
    or after the first content-mode userscript runs in `injected/content` */
 
-export const {
-  assign,
-  defineProperty,
-  getOwnPropertyDescriptor: describeProperty,
+const {
   entries: objectEntries,
   keys: objectKeys,
   values: objectValues,
 } = Object;
-const { forEach, reduce } = Array.prototype;
+const { forEach, reduce } = [];
 
 export function normalizeKeys(key) {
   if (key == null) return [];

+ 13 - 0
src/common/safe-globals.js

@@ -0,0 +1,13 @@
+/* eslint-disable no-unused-vars */
+
+// Not exporting the built-in globals because this also runs in node
+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 */
+  document,
+  window,
+} = this || {};
+export const { hasOwnProperty } = {};
+export const safeCall = hasOwnProperty.call.bind(hasOwnProperty.call);

+ 1 - 2
src/common/ui/externals.vue

@@ -34,7 +34,6 @@
 
 <script>
 import { string2uint8array } from '#/common';
-import { objectEntries } from '#/common/object';
 import VmCode from '#/common/ui/code';
 import storage from '#/common/storage';
 
@@ -48,7 +47,7 @@ export default {
       return [
         ...mainUrl ? [[this.i18n('editNavCode'), mainUrl, code]] : [],
         ...require.map(url => ['@require', url, deps[`0${url}`]]),
-        ...objectEntries(resources).map(([id, url]) => [`@resource ${id}`, url, deps[`1${url}`]]),
+        ...Object.entries(resources).map(([id, url]) => [`@resource ${id}`, url, deps[`1${url}`]]),
       ];
     },
   },

+ 1 - 3
src/common/util.js

@@ -7,7 +7,7 @@ import { browser } from '#/common/consts';
 // used in an unsafe context so we need to save the original functions
 const perfNow = performance.now.bind(performance);
 const { random, floor } = Math;
-export const { toString: numberToString } = 0;
+const { toString: numberToString } = 0;
 
 export function i18n(name, args) {
   return browser.i18n.getMessage(name, args) || name;
@@ -187,8 +187,6 @@ export function formatTime(duration) {
   return `${duration | 0}${unitInfo[0]}`;
 }
 
-// used in an unsafe context so we need to save the original functions
-export const { hasOwnProperty } = {};
 export function isEmpty(obj) {
   for (const key in obj) {
     if (obj::hasOwnProperty(key)) {

+ 2 - 3
src/injected/content/bridge.js

@@ -1,7 +1,5 @@
 import { sendCmd } from '#/common';
 import { INJECT_PAGE, browser } from '#/common/consts';
-import { assign } from '#/common/object';
-import { Error } from '../utils/helpers';
 
 /** @type {Object.<string, MessageFromGuestHandler>} */
 const handlers = {};
@@ -39,7 +37,8 @@ const bridge = {
 export default bridge;
 
 browser.runtime.onMessage.addListener(({ cmd, data }, src) => {
-  bgHandlers[cmd]?.(data, src);
+  const fn = bgHandlers[cmd];
+  if (fn) fn(data, src);
 });
 
 /**

+ 1 - 2
src/injected/content/clipboard.js

@@ -1,6 +1,5 @@
 import { sendCmd } from '#/common';
-import { describeProperty } from '#/common/object';
-import { addEventListener, document, logging, removeEventListener } from '../utils/helpers';
+import { logging } from '../utils/helpers';
 import bridge from './bridge';
 
 // old Firefox defines it on a different prototype so we'll just grab it from document directly

+ 2 - 4
src/injected/content/index.js

@@ -1,10 +1,8 @@
 import { getUniqId, isEmpty, sendCmd } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
-import { assign, objectKeys, objectPick } from '#/common/object';
+import { objectPick } from '#/common/object';
 import { bindEvents } from '../utils';
-import {
-  forEach, includes, append, createElementNS, document, setAttribute, NS_HTML,
-} from '../utils/helpers';
+import { NS_HTML } from '../utils/helpers';
 import bridge from './bridge';
 import './clipboard';
 import { appendToRoot, injectPageSandbox, injectScripts } from './inject';

+ 3 - 6
src/injected/content/inject.js

@@ -1,11 +1,7 @@
 import { INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser } from '#/common/consts';
 import { sendCmd } from '#/common';
-import { defineProperty, describeProperty, forEachKey } from '#/common/object';
-import {
-  append, appendChild, createElementNS, elemByTag, remove, NS_HTML,
-  addEventListener, document, removeEventListener,
-  forEach, log, Promise, push, then,
-} from '../utils/helpers';
+import { forEachKey } from '#/common/object';
+import { elemByTag, NS_HTML, log } from '../utils/helpers';
 import bridge from './bridge';
 
 // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1408996
@@ -93,6 +89,7 @@ export async function injectScripts(contentId, webId, data, isXml) {
   };
   const feedback = data.scripts.map((script) => {
     const { id } = script.props;
+    // eslint-disable-next-line no-restricted-syntax
     const realm = INJECT_MAPPING[script.injectInto].find(key => realms[key]?.injectable());
     // If the script wants this specific realm, which is unavailable, we won't inject it at all
     if (realm) {

+ 0 - 2
src/injected/content/notifications.js

@@ -1,6 +1,4 @@
 import { sendCmd } from '#/common';
-import { objectEntries } from '#/common/object';
-import { filter } from '../utils/helpers';
 import bridge from './bridge';
 
 const notifications = {};

+ 0 - 1
src/injected/content/requests.js

@@ -1,5 +1,4 @@
 import { sendCmd } from '#/common';
-import { includes } from '../utils/helpers';
 import bridge from './bridge';
 
 const { fetch } = global;

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

@@ -0,0 +1,19 @@
+/* eslint-disable no-unused-vars */
+
+// Not exporting the built-in globals because this also runs in node
+const { CustomEvent, Error, dispatchEvent } = this || {};
+export const { createElementNS, getElementsByTagName } = document;
+export const { then } = Promise.prototype;
+export const { filter, forEach, includes, 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;

+ 2 - 18
src/injected/utils/helpers.js

@@ -1,26 +1,11 @@
-// caching native properties to avoid being overridden, see violentmonkey/violentmonkey#151
-import { numberToString } from '#/common';
-import { assign, objectKeys } from '#/common/object';
-
-/* 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 */
-export const { document, Error, Promise, Uint8Array } = global;
-export const { then } = Promise.prototype;
-export const { filter, forEach, includes, join, map, push } = [];
-export const { charCodeAt, slice, replace } = '';
-export const { toString: objectToString } = {};
-export const { append, appendChild, remove, setAttribute } = Element.prototype;
-export const {
-  addEventListener, createElementNS, getElementsByTagName, removeEventListener,
-} = document;
 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 { Boolean, isFinite } = global; // eslint-disable-line no-restricted-properties
+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]'
@@ -41,7 +26,6 @@ const escMap = {
 };
 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)}`;
-export const jsonLoad = JSON.parse;
 // 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

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

@@ -1,7 +1,3 @@
-import { addEventListener, document } from './helpers';
-
-const { CustomEvent, dispatchEvent } = global;
-
 export function bindEvents(srcId, destId, handle, cloneInto) {
   document::addEventListener(srcId, e => handle(e.detail));
   const pageContext = cloneInto && document.defaultView;

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

@@ -1,6 +1,4 @@
 import { getUniqId } from '#/common';
-import { assign } from '#/common/object';
-import { Promise } from '../utils/helpers';
 
 const handlers = {};
 const callbacks = {};
@@ -11,7 +9,8 @@ const bridge = {
     assign(handlers, obj);
   },
   onHandle({ cmd, data }) {
-    handlers[cmd]?.(data);
+    const fn = handlers[cmd];
+    if (fn) fn(data);
   },
   send(cmd, data) {
     return new Promise(resolve => {

+ 4 - 11
src/injected/web/gm-api.js

@@ -1,19 +1,12 @@
 import { dumpScriptValue, getUniqId, isEmpty } from '#/common/util';
-import {
-  assign, defineProperty, objectEntries, objectKeys, objectPick, objectValues,
-} from '#/common/object';
+import { objectPick } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
 import { onTabCreate } from './tabs';
 import { atob, onRequestCreate } from './requests';
 import { onNotificationCreate } from './notifications';
-import {
-  decodeValue, dumpValue, loadValues, changeHooks,
-} from './gm-values';
-import {
-  Error, Uint8Array, charCodeAt, jsonDump, log, logging, slice, then,
-  NS_HTML, appendChild, createElementNS, document, elemByTag, remove, setAttribute,
-} from '../utils/helpers';
+import { decodeValue, dumpValue, loadValues, changeHooks } from './gm-values';
+import { jsonDump, log, logging, NS_HTML, elemByTag } from '../utils/helpers';
 
 const {
   Blob, MouseEvent, TextDecoder,
@@ -300,7 +293,7 @@ function downloadBlob(res) {
   downloadChain = downloadChain::then(async () => {
     a::dispatchEvent(new MouseEvent('click'));
     revokeBlobAfterTimeout(url);
-    try { onload?.(res); } catch (e) { log('error', ['GM_download', 'callback'], e); }
+    try { if (onload) onload(res); } catch (e) { log('error', ['GM_download', 'callback'], e); }
     await bridge.send('SetTimeout', 100);
   });
 }

+ 3 - 3
src/injected/web/gm-values.js

@@ -1,7 +1,7 @@
-import { forEachEntry, objectValues } from '#/common/object';
+import { forEachEntry } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
-import { jsonLoad, forEach, slice, log } from '../utils/helpers';
+import { log } from '../utils/helpers';
 
 const { Number } = global;
 
@@ -9,7 +9,7 @@ const { Number } = global;
 export const changeHooks = {};
 
 const dataDecoders = {
-  o: jsonLoad,
+  o: jsonParse,
   n: Number,
   b: val => val === 'true',
 };

+ 2 - 10
src/injected/web/gm-wrapper.js

@@ -1,11 +1,5 @@
-import { hasOwnProperty } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
-import { assign, defineProperty, describeProperty, objectKeys } from '#/common/object';
 import bridge from './bridge';
-import {
-  filter, forEach, includes, map, slice,
-  addEventListener, removeEventListener,
-} from '../utils/helpers';
 import { makeGmApi, vmOwnFunc } from './gm-api';
 
 const {
@@ -25,8 +19,6 @@ const { startsWith } = '';
 let gmApi;
 let gm4Api;
 let componentUtils;
-// making a local copy to avoid using webpack's import wrappers as .has() is invoked **a lot**
-const has = hasOwnProperty;
 const IS_TOP = window.top === window;
 
 export function wrapGM(script) {
@@ -296,7 +288,7 @@ function makeGlobalWrapper(local) {
     get(_, name) {
       if (name !== 'undefined' && name !== scopeSym) {
         const value = local[name];
-        return value !== undefined || local::has(name)
+        return value !== undefined || local::hasOwnProperty(name)
           ? value
           : resolveProp(name);
       }
@@ -317,7 +309,7 @@ function makeGlobalWrapper(local) {
       return desc;
     },
     has(_, name) {
-      return name === 'undefined' || local::has(name) || globals.has(name);
+      return name === 'undefined' || local::hasOwnProperty(name) || globals.has(name);
     },
     ownKeys() {
       return [...globals]::concat(

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

@@ -1,7 +1,6 @@
 import { INJECT_PAGE, INJECT_CONTENT } from '#/common/consts';
-import { assign, defineProperty, describeProperty } from '#/common/object';
 import { bindEvents } from '../utils';
-import { document, forEach, log, logging, remove, Promise, then } from '../utils/helpers';
+import { log, logging } from '../utils/helpers';
 import bridge from './bridge';
 import { wrapGM } from './gm-wrapper';
 import store from './store';
@@ -12,7 +11,6 @@ import './tabs';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
-const { window } = global;
 const { KeyboardEvent, MouseEvent } = global;
 const { get: getCurrentScript } = describeProperty(Document.prototype, 'currentScript');
 
@@ -50,10 +48,12 @@ export default function initialize(
 bridge.addHandlers({
   Command([cmd, evt]) {
     const constructor = evt.key ? KeyboardEvent : MouseEvent;
-    store.commands[cmd]?.(new constructor(evt.type, evt));
+    const fn = store.commands[cmd];
+    if (fn) fn(new constructor(evt.type, evt));
   },
   Callback({ callbackId, payload }) {
-    bridge.callbacks[callbackId]?.(payload);
+    const fn = bridge.callbacks[callbackId];
+    if (fn) fn(payload);
   },
   ScriptData({ info, items, runAt }) {
     if (info) {

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

@@ -5,13 +5,15 @@ const notifications = {};
 
 bridge.addHandlers({
   NotificationClicked(id) {
-    notifications[id]?.onclick?.();
+    const fn = notifications[id]?.onclick;
+    if (fn) fn();
   },
   NotificationClosed(id) {
     const options = notifications[id];
     if (options) {
       delete notifications[id];
-      options.ondone?.();
+      const fn = options.ondone;
+      if (fn) fn();
     }
   },
 });

+ 3 - 7
src/injected/web/requests.js

@@ -1,9 +1,5 @@
-import { assign, defineProperty, describeProperty, objectPick } from '#/common/object';
-import {
-  Error, Promise, Uint8Array,
-  charCodeAt, filter, forEach, jsonLoad, log, replace, then,
-  NS_HTML, addEventListener, createElementNS, setAttribute,
-} from '../utils/helpers';
+import { objectPick } from '#/common/object';
+import { log, NS_HTML } from '../utils/helpers';
 import bridge from './bridge';
 
 const idMap = {};
@@ -57,7 +53,7 @@ function parseData(req, msg) {
   if (responseType === 'text') {
     res = raw;
   } else if (responseType === 'json') {
-    res = jsonLoad(raw);
+    res = jsonParse(raw);
   } else if (responseType === 'document') {
     const type = msg.contentType::replace(/^[^;]+/)?.[0] || 'text/html';
     res = new DOMParser()::parseFromString(raw, type);

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

@@ -8,8 +8,9 @@ bridge.addHandlers({
     const item = tabs[key];
     if (item) {
       item.closed = true;
-      item.onclose?.();
       delete tabs[key];
+      const fn = item.onclose;
+      if (fn) fn();
     }
   },
 });

+ 1 - 1
src/options/views/tab-settings/index.vue

@@ -112,7 +112,7 @@
 
 <script>
 import Tooltip from 'vueleton/lib/tooltip/bundle';
-import { debounce, hasOwnProperty, i18n } from '#/common';
+import { debounce, i18n } from '#/common';
 import { INJECT_AUTO, INJECT_PAGE, INJECT_CONTENT } from '#/common/consts';
 import SettingCheck from '#/common/ui/setting-check';
 import { forEachEntry, mapEntry } from '#/common/object';

+ 5 - 0
test/mock/polyfill.js

@@ -37,3 +37,8 @@ global.URL = {
     return blobUrl;
   },
 };
+
+const globalsCommon = require('#/common/safe-globals');
+const globalsInjected = require('#/injected/safe-injected-globals');
+
+Object.assign(global, globalsCommon, globalsInjected);

+ 10 - 0
yarn.lock

@@ -1366,6 +1366,11 @@ acorn@^7.1.1:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf"
   integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==
 
+acorn@^8.4.1:
+  version "8.4.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c"
+  integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==
+
 aggregate-error@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0"
@@ -2677,6 +2682,11 @@ concat-stream@^1.5.0, concat-stream@^1.6.0:
     readable-stream "^2.2.2"
     typedarray "^0.0.6"
 
+confusing-browser-globals@^1.0.10:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59"
+  integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==
+
 confusing-browser-globals@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz#72bc13b483c0276801681871d4898516f8f54fdd"