Browse Source

Merge pull request #842 from tophf/fix-sandbox

Fix sandbox
Gerald 5 years ago
parent
commit
c5baa23ff2

+ 13 - 4
scripts/webpack.conf.js

@@ -62,15 +62,24 @@ const modify = (extra, 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[Symbol.for('${INIT_FUNC_NAME}')] !== 1)`;
+const skipReinjectionConfig = (config, test) => config.plugins.push(
+  new WrapperWebpackPlugin({
+    header: skipReinjectionHeader,
+    ...test && { test },
+  }));
+
 module.exports = Promise.all([
-  modify(),
+  modify(null, config => skipReinjectionConfig(config, /^browser\.js$/)),
   modify({
     pages: {
       injected: {
         entry: './src/injected',
       },
     },
-  }),
+  }, skipReinjectionConfig),
   modify({
     pages: {
       'injected-web': {
@@ -81,8 +90,8 @@ module.exports = Promise.all([
     config.output.libraryTarget = 'commonjs2';
     config.plugins.push(
       new WrapperWebpackPlugin({
-        header: `\
-          window.${INIT_FUNC_NAME} = function () {
+        header: `${skipReinjectionHeader}
+          window[Symbol.for('${INIT_FUNC_NAME}')] = function () {
             var module = { exports: {} };
           `,
         footer: `

+ 4 - 1
src/common/browser.js

@@ -2,7 +2,10 @@
 // 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 (!global.browser?.runtime?.sendMessage
+// Also don't redefine our `browser` on content script reinjection due to new documentElement
+// because `chrome` was already deleted by us and now it can be spoofed by a userscript
+&& window[Symbol.for(process.env.INIT_FUNC_NAME)] !== 1) {
   const { chrome, Promise } = global;
   const wrapAPIs = (source, meta = {}) => {
     return Object.entries(source)

+ 1 - 1
src/common/util.js

@@ -112,7 +112,7 @@ export function formatTime(duration) {
 }
 
 // used in an unsafe context so we need to save the original functions
-const { hasOwnProperty } = Object.prototype;
+export const { hasOwnProperty } = Object.prototype;
 export function isEmpty(obj) {
   for (const key in obj) {
     if (obj::hasOwnProperty(key)) {

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

@@ -1,5 +1,7 @@
 import { sendCmd } from '../utils';
-import { addEventListener, describeProperty, logging } from '../utils/helpers';
+import {
+  addEventListener, describeProperty, logging, removeEventListener,
+} from '../utils/helpers';
 import bridge from './bridge';
 
 // old Firefox defines it on a different prototype so we'll just grab it from document directly
@@ -7,7 +9,6 @@ const { execCommand } = document;
 const { setData } = DataTransfer.prototype;
 const { get: getClipboardData } = describeProperty(ClipboardEvent.prototype, 'clipboardData');
 const { preventDefault, stopImmediatePropagation } = Event.prototype;
-const { removeEventListener } = EventTarget.prototype;
 
 let clipboardData;
 

+ 13 - 6
src/injected/content/index.js

@@ -6,7 +6,7 @@ import {
 } from '../utils/helpers';
 import bridge from './bridge';
 import './clipboard';
-import injectScripts from './inject';
+import { injectPageSandbox, injectScripts } from './inject';
 import './notifications';
 import './requests';
 import './tabs';
@@ -17,17 +17,24 @@ const menus = {};
 // Make sure to call obj::method() in code that may run after INJECT_CONTENT userscripts
 const { split } = String.prototype;
 
-export default async function initialize(contentId, webId) {
-  const data = await sendCmd('GetInjected', null, { retry: true });
+(async () => {
+  const contentId = getUniqId();
+  const webId = getUniqId();
+  // injecting right now before site scripts can mangle globals or intercept our contentId
+  // except for XML documents as their appearance breaks, but first we're sending
+  // a request for the data because injectPageSandbox takes ~5ms
+  const dataPromise = sendCmd('GetInjected', null, { retry: true });
+  const isXml = document instanceof XMLDocument;
+  if (!isXml) injectPageSandbox(contentId, webId);
+  const data = await dataPromise;
   // 1) bridge.post may be overridden in injectScripts
   // 2) cloneInto is provided by Firefox in content scripts to expose data to the page
   bridge.post = bindEvents(contentId, webId, bridge.onHandle, global.cloneInto);
-  bridge.destId = webId;
   bridge.isFirefox = data.isFirefox;
-  if (data.scripts) injectScripts(contentId, webId, data);
+  if (data.scripts) injectScripts(contentId, webId, data, isXml);
   getPopup();
   setBadge();
-}
+})();
 
 bridge.addBackgroundHandlers({
   Command(data) {

+ 29 - 56
src/injected/content/inject.js

@@ -5,29 +5,33 @@ import {
   INJECT_PAGE,
   browser,
 } from '#/common/consts';
-import { getUniqId, sendCmd } from '#/common';
+import { sendCmd } from '#/common';
 
-import { attachFunction } from '../utils';
 import {
-  forEach, join, append, createElementNS, NS_HTML,
-  charCodeAt, fromCharCode,
+  forEach, join, append, createElementNS, defineProperty, NS_HTML,
+  charCodeAt, fromCharCode, replace, remove,
 } from '../utils/helpers';
 import bridge from './bridge';
 
 // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1408996
-const VMInitInjection = window[process.env.INIT_FUNC_NAME];
-delete window[process.env.INIT_FUNC_NAME];
+const VMInitInjection = window[Symbol.for(process.env.INIT_FUNC_NAME)];
+// To avoid running repeatedly due to new `document.documentElement`
+// (the symbol is undeletable so a userscript can't fool us on reinjection)
+defineProperty(window, Symbol.for(process.env.INIT_FUNC_NAME), { value: 1 });
 
 const { encodeURIComponent } = global;
-const { replace } = String.prototype;
-const { remove } = Element.prototype;
 
 bridge.addHandlers({
   Inject: injectScript,
   InjectMulti: data => data::forEach(injectScript),
 });
 
-export default function injectScripts(contentId, webId, data) {
+export function injectPageSandbox(contentId, webId) {
+  inject(`(${VMInitInjection}())('${webId}','${contentId}')`,
+    browser.runtime.getURL('sandbox/injected-web.js'));
+}
+
+export function injectScripts(contentId, webId, data, isXml) {
   const injectPage = [];
   const injectContent = [];
   const scriptLists = {
@@ -45,7 +49,7 @@ export default function injectScripts(contentId, webId, data) {
     }
     return false;
   });
-  let injectable;
+  let injectable = isXml ? false : null;
   const injectChecking = {
     // eslint-disable-next-line no-return-assign
     [INJECT_PAGE]: () => injectable ?? (injectable = checkInjectable()),
@@ -57,14 +61,8 @@ export default function injectScripts(contentId, webId, data) {
     const availableInjectInto = internalInjectInto.find(key => injectChecking[key]?.());
     scriptLists[availableInjectInto]?.push({ script, injectInto: availableInjectInto });
   });
-  const args = [
-    webId,
-    contentId,
-    data.ua,
-    data.isFirefox,
-  ];
   if (injectContent.length) {
-    const invokeGuest = VMInitInjection()(...args, bridge.onHandle);
+    const invokeGuest = VMInitInjection()(webId, contentId, bridge.onHandle);
     const postViaBridge = bridge.post;
     bridge.invokableIds.push(...injectContent.map(({ script }) => script.props.id));
     bridge.post = (cmd, params, realm) => {
@@ -77,8 +75,6 @@ export default function injectScripts(contentId, webId, data) {
     }, INJECT_CONTENT);
   }
   if (injectPage.length) {
-    inject(`(${VMInitInjection}())(${JSON.stringify(args).slice(1, -1)})`,
-      browser.runtime.getURL('sandbox/injected-web.js'));
     bridge.post('LoadScripts', {
       ...data,
       mode: INJECT_PAGE,
@@ -88,53 +84,31 @@ export default function injectScripts(contentId, webId, data) {
 }
 
 function checkInjectable() {
-  // Check default namespace, `a.style` only exists in HTML namespace
-  if (!('style' in document.createElement('a'))) return false;
-  const id = getUniqId('VM-');
-  const detect = (domId) => {
-    const a = document.createElement('a');
-    a.id = domId;
-    document.documentElement.appendChild(a);
-  };
-  inject(`(${detect})(${JSON.stringify(id)})`);
-  const a = document.querySelector(`#${id}`);
-  const injectable = !!a;
-  if (a) a.remove();
-  return injectable;
+  let res = false;
+  bridge.addHandlers({
+    Pong() {
+      res = true;
+    },
+  });
+  bridge.post('Ping');
+  return res;
 }
 
-const injectedScriptIntro = `(${
-  (attach, id, cb, callbackId) => {
-    attach(id, cb);
-    const callback = window[callbackId];
-    if (callback) callback();
-  }
-})(${attachFunction},`;
-
 // fullwidth range starts at 0xFF00
 // normal range starts at space char code 0x20
 const replaceWithFullWidthForm = s => fromCharCode(s::charCodeAt(0) - 0x20 + 0xFF00);
 
-function injectScript(data) {
-  const [vId, codeSlices, vCallbackId, mode, scriptId, scriptName] = data;
-  // trying to avoid string concatenation of potentially huge code slices as long as possible
-  const injectedCode = [
-    injectedScriptIntro,
-    `"${vId}",`,
-    ...codeSlices,
-    `,"${vCallbackId}");`,
-  ];
+function injectScript([codeSlices, mode, scriptId, scriptName]) {
   // using fullwidth forms for special chars and those added by the newer RFC3986 spec for URI
   const name = encodeURIComponent(scriptName::replace(/[#&',/:;?@=]/g, replaceWithFullWidthForm));
   const sourceUrl = browser.extension.getURL(`${name}.user.js#${scriptId}`);
+  // trying to avoid string concatenation of potentially huge code slices for as long as possible
   if (mode === INJECT_CONTENT) {
-    injectedCode.push(
-      ';0\n//# sourceURL=', // Firefox: the injected script must return 0 at the end
-      sourceUrl,
-    );
-    sendCmd('InjectScript', injectedCode::join(''));
+    // Firefox: the injected script must return 0 at the end
+    codeSlices.push(`;0\n//# sourceURL=${sourceUrl}`);
+    sendCmd('InjectScript', codeSlices::join(''));
   } else {
-    inject(injectedCode, sourceUrl);
+    inject(codeSlices, sourceUrl);
   }
 }
 
@@ -142,7 +116,6 @@ function inject(code, sourceUrl) {
   const script = document::createElementNS(NS_HTML, 'script');
   // avoid string concatenation of |code| as it can be extremely long
   script::append(
-    'document.currentScript.remove();',
     ...typeof code === 'string' ? [code] : code,
     ...sourceUrl ? ['\n//# sourceURL=', sourceUrl] : [],
   );

+ 23 - 33
src/injected/index.js

@@ -1,41 +1,31 @@
-import { getUniqId, sendCmd } from './utils';
-import { addEventListener, describeProperty, match } from './utils/helpers';
-import initialize from './content';
-
-(function main() {
-  // Avoid running repeatedly due to new `document.documentElement`
-  const VM_KEY = '__Violentmonkey';
-  // Literal `1` guards against <html id="__Violentmonkey">, more info in browser.js
-  if (window[VM_KEY] === 1) return;
-  window[VM_KEY] = 1;
-
-  function initBridge() {
-    const contentId = getUniqId();
-    const webId = getUniqId();
-    initialize(contentId, webId);
-  }
-
-  initBridge();
+import { sendCmd } from './utils';
+import './content';
 
+// Script installation
+// Firefox does not support `onBeforeRequest` for `file:`
+if (global.location.pathname.endsWith('.user.js')) {
   const { go } = History.prototype;
+  const { document, history } = global;
   const { querySelector } = Document.prototype;
-  const { get: getReadyState } = describeProperty(Document.prototype, 'readyState');
-  // For installation
-  // Firefox does not support `onBeforeRequest` for `file:`
-  async function checkJS() {
+  const referrer = document.referrer;
+  (async () => {
+    if (document.readyState !== 'complete') {
+      await new Promise(resolve => {
+        global.addEventListener('load', resolve, { once: true });
+      });
+    }
+    // plain text shouldn't have a <title>
     if (!document::querySelector('title')) {
-      // plain text
       await sendCmd('ConfirmInstall', {
         code: document.body.textContent,
-        url: window.location.href,
-        from: document.referrer,
+        url: global.location.href,
+        from: referrer,
       });
-      if (window.history.length > 1) window.history::go(-1);
-      else sendCmd('TabClose');
+      if (history.length > 1) {
+        history::go(-1);
+      } else {
+        sendCmd('TabClose');
+      }
     }
-  }
-  if (window.location.pathname::match(/\.user\.js$/)) {
-    if (document::getReadyState() === 'complete') checkJS();
-    else window::addEventListener('load', checkJS, { once: true });
-  }
-}());
+  })();
+}

+ 6 - 5
src/injected/utils/helpers.js

@@ -3,7 +3,7 @@
 // eslint-disable-next-line no-restricted-properties
 export const {
   // types
-  Blob, Boolean, Error, Promise, Uint8Array,
+  Boolean, Error, Promise, Uint8Array,
   // props and methods
   atob, isFinite, setTimeout,
 } = global;
@@ -15,15 +15,16 @@ export const {
 
 export const {
   keys: objectKeys, values: objectValues, entries: objectEntries,
-  assign, defineProperty, defineProperties, getOwnPropertyDescriptor: describeProperty,
+  assign, defineProperty, getOwnPropertyDescriptor: describeProperty,
 } = Object;
-export const { charCodeAt, match, slice } = String.prototype;
+export const {
+  charCodeAt, match, slice, replace,
+} = String.prototype;
 export const { toString: objectToString } = Object.prototype;
 const { toString: numberToString } = Number.prototype;
-const { replace } = String.prototype;
 export const { fromCharCode } = String;
 export const { addEventListener, removeEventListener } = EventTarget.prototype;
-export const { append, setAttribute } = Element.prototype;
+export const { append, remove, setAttribute } = Element.prototype;
 export const { createElementNS } = Document.prototype;
 export const logging = assign({}, console);
 

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

@@ -16,14 +16,3 @@ export function bindEvents(srcId, destId, handle, cloneInto) {
     document::dispatchEvent(e);
   };
 }
-
-// it's injected as a string so only the page functions can be used
-export function attachFunction(id, cb) {
-  Object.defineProperty(window, id, {
-    value(...args) {
-      cb.apply(this, args);
-      delete window[id];
-    },
-    configurable: true,
-  });
-}

+ 112 - 104
src/injected/web/gm-wrapper.js

@@ -1,21 +1,30 @@
+import { hasOwnProperty as has } from '#/common';
 import { INJECT_CONTENT, METABLOCK_RE } from '#/common/consts';
 import bridge from './bridge';
 import {
-  filter, forEach, includes, map, match, slice, assign, defineProperty, defineProperties,
-  describeProperty, objectKeys, addEventListener, removeEventListener,
+  concat, filter, forEach, includes, indexOf, map, match, push, slice,
+  defineProperty, describeProperty, objectKeys, replace,
+  addEventListener, removeEventListener,
 } from '../utils/helpers';
 import { makeGmApi } from './gm-api';
 
 const { Proxy } = global;
-const { hasOwnProperty: has } = Object.prototype;
+const { getOwnPropertyNames, getOwnPropertySymbols } = Object;
+const { splice } = Array.prototype;
 const { startsWith } = String.prototype;
 
 let gmApi;
 let gm4Api;
 let componentUtils;
-const propertyToString = {
-  toString: () => '[Violentmonkey property]',
+let windowClose;
+const vmOwnFuncToString = () => '[Violentmonkey property]';
+const vmOwnFunc = (func, toString) => {
+  func.toString = toString || vmOwnFuncToString;
+  return func;
 };
+const vmSandboxedFuncToString = nativeFunc => () => (
+  `${nativeFunc}`::replace('native code', 'Violentmonkey sandbox')
+);
 
 export function deletePropsCache() {
   // let GC sweep the no longer necessary stuff
@@ -27,29 +36,20 @@ export function deletePropsCache() {
 export function wrapGM(script, code, cache, injectInto) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
-  const gm = {};
   const grant = script.meta.grant || [];
-  let thisObj = gm;
-  if (!grant.length || (grant.length === 1 && grant[0] === 'none')) {
-    // @grant none
+  if (grant.length === 1 && grant[0] === 'none') {
     grant.length = 0;
-    gm.window = global;
-  } else {
-    thisObj = makeGlobalWrapper();
-    gm.window = thisObj;
-  }
-  if (grant::includes('window.close')) {
-    gm.window.close = () => bridge.post('TabClose');
   }
   const resources = script.meta.resources || {};
   const gmInfo = makeGmInfo(script, code, resources, injectInto);
-  const gm4Props = { info: { value: gmInfo } };
-  const gm4Object = {};
-  const grantedProps = {
+  const gm = {
+    GM: { info: gmInfo },
+    GM_info: gmInfo,
+    unsafeWindow: global,
     ...componentUtils || (componentUtils = makeComponentUtils()),
-    unsafeWindow: { value: global },
-    GM_info: { value: gmInfo },
-    GM: { value: gm4Object },
+    ...grant::includes('window.close') && windowClose || (windowClose = {
+      close: vmOwnFunc(() => bridge.post('TabClose')),
+    }),
   };
   const context = {
     cache,
@@ -65,14 +65,12 @@ export function wrapGM(script, code, cache, injectInto) {
     const gm4 = gm4Api[gm4name];
     const method = gmApi[gm4 ? `GM_${gm4.alias || gm4name}` : name];
     if (method) {
-      const prop = makeGmMethodProp(method, context, gm4?.async);
-      if (gm4) gm4Props[gm4name] = prop;
-      else grantedProps[name] = prop;
+      const caller = makeGmMethodCaller(method, context, gm4?.async);
+      if (gm4) gm.GM[gm4name] = caller;
+      else gm[name] = caller;
     }
   });
-  defineProperties(gm4Object, gm4Props);
-  defineProperties(gm, grantedProps);
-  return { gm, thisObj, keys: objectKeys(grantedProps) };
+  return grant.length ? makeGlobalWrapper(gm) : gm;
 }
 
 function makeGmInfo({ config, meta, props }, code, resources, injectInto) {
@@ -102,16 +100,13 @@ function makeGmInfo({ config, meta, props }, code, resources, injectInto) {
   };
 }
 
-function makeGmMethodProp(gmMethod, context, isAsync) {
-  return {
-    // keeping the native console.log intact
-    value: gmMethod === gmApi.GM_log ? gmMethod : assign(
-      isAsync
-        ? (async (...args) => context::gmMethod(...args))
-        : ((...args) => context::gmMethod(...args)),
-      propertyToString,
-    ),
-  };
+function makeGmMethodCaller(gmMethod, context, isAsync) {
+  // keeping the native console.log intact
+  return gmMethod === gmApi.GM_log ? gmMethod : vmOwnFunc(
+    isAsync
+      ? (async (...args) => context::gmMethod(...args))
+      : ((...args) => context::gmMethod(...args)),
+  );
 }
 
 // https://html.spec.whatwg.org/multipage/window-object.html#the-window-object
@@ -181,78 +176,96 @@ const boundGlobals = [
   'setTimeout',
   'stop',
 ];
-const boundGlobalsRunner = {
-  apply: (fn, thisArg, args) => fn.apply(global, args),
-};
+const boundGlobalsRunner = (func, thisArg) => (...args) => thisArg::func(...args);
 /**
  * @desc Wrap helpers to prevent unexpected modifications.
  */
-function makeGlobalWrapper() {
-  const props = {};
+function makeGlobalWrapper(local) {
   const events = {};
-  const deleted = {};
-  // - Chrome, `global === window`
-  // - Firefox, `global` is a sandbox, `global.window === window`:
-  //   - some properties (like `isFinite`) are defined in `global` but not `window`
-  //   - all `window` properties can be accessed from `global`
-  const wrapper = new Proxy(props, {
+  const deleted = []; // using an array to skip building it in ownKeys()
+  const scopeSym = Symbol.unscopables;
+  /*
+   - Chrome, `global === window`
+   - Firefox, `global` is a sandbox, `global.window === window`:
+     - some properties (like `isFinite`) are defined in `global` but not `window`
+     - all `window` properties can be accessed from `global`
+  */
+  const wrapper = new Proxy(local, {
     defineProperty(_, name, info) {
-      if (unforgeableGlobals::includes(name) || isUnforgeableFrameIndex(name)) return false;
-      defineProperty(props, name, info);
-      if (name::startsWith('on')) {
+      if (typeof name !== 'symbol'
+      && (unforgeableGlobals::includes(name) || isUnforgeableFrameIndex(name))) return false;
+      defineProperty(local, name, info);
+      if (typeof name === 'string' && name::startsWith('on')) {
         setEventHandler(name::slice(2));
       }
-      delete deleted[name];
+      undelete(name);
       return true;
     },
     deleteProperty(_, name) {
-      if (unforgeableGlobals::includes(name) || deleted::has(name)) return false;
-      if (isUnforgeableFrameIndex(name)) return true;
-      if (global::has(name)) deleted[name] = 1;
-      return delete props[name];
+      if (unforgeableGlobals::includes(name)) return false;
+      if (isUnforgeableFrameIndex(name) || deleted::includes(name)) return true;
+      if (global::has(name)) deleted::push(name);
+      return delete local[name];
     },
     get(_, name) {
-      if (!deleted::has(name)) {
-        const value = props[name];
-        return value !== undefined || props::has(name) ? value : resolveProp(name);
-      }
+      const value = local[name];
+      return value !== undefined || name === scopeSym || deleted::includes(name) || local::has(name)
+        ? value
+        : resolveProp(name);
     },
     getOwnPropertyDescriptor(_, name) {
-      const desc = describeProperty(props, name) ?? describeProperty(wrapper[name]);
-      if (desc?.value === window) desc.value = wrapper;
-      return desc;
+      if (!deleted::includes(name)) {
+        const ownDesc = describeProperty(local, name);
+        const desc = ownDesc || 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 (!ownDesc && !desc.configurable) defineProperty(local, name, desc);
+        return desc;
+      }
     },
     has(_, name) {
-      return props::has(name)
-        || !deleted::has(name) && global::has(name);
+      return local::has(name)
+        || !deleted::includes(name) && global::has(name);
     },
     ownKeys() {
-      const modifiedKeys = objectKeys(props);
-      const deletedKeys = objectKeys(deleted);
-      let keys = objectKeys(global);
-      if (deletedKeys.length || modifiedKeys.length) {
-        keys = keys::filter(k => !deletedKeys::includes(k) && !modifiedKeys::includes(k));
-      }
-      return modifiedKeys.length ? [...keys, ...modifiedKeys] : keys;
+      return []::concat(
+        ...filterGlobals(getOwnPropertyNames),
+        ...filterGlobals(getOwnPropertySymbols),
+      );
     },
+    preventExtensions() {},
     set(_, name, value) {
       if (unforgeableGlobals::includes(name)) return false;
-      delete deleted[name];
+      undelete(name);
       if (readonlyGlobals::includes(name) || isUnforgeableFrameIndex(name)) return true;
-      props[name] = value;
-      if (name::startsWith('on') && window::has(name)) {
+      local[name] = value;
+      if (typeof name === 'string' && name::startsWith('on') && window::has(name)) {
         setEventHandler(name::slice(2), value);
       }
       return true;
     },
   });
+  function filterGlobals(describer) {
+    const globalKeys = describer(global);
+    const localKeys = describer(local);
+    return [
+      deleted.length
+        ? globalKeys::filter(key => !deleted::includes(key))
+        : globalKeys,
+      localKeys::filter(key => !globalKeys::includes(key)),
+    ];
+  }
   function resolveProp(name) {
     let value = global[name];
     if (value === window) {
       value = wrapper;
     } else if (boundGlobals::includes(name)) {
-      value = new Proxy(value, boundGlobalsRunner);
-      props[name] = value;
+      value = vmOwnFunc(
+        boundGlobalsRunner(value, global),
+        vmSandboxedFuncToString(value),
+      );
+      local[name] = value;
     }
     return value;
   }
@@ -261,11 +274,15 @@ function makeGlobalWrapper() {
     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] = (...args) => value.apply(window, args));
+      window::addEventListener(name, events[name] = boundGlobalsRunner(value, window));
     } else {
       delete events[name];
     }
   }
+  function undelete(name) {
+    const i = deleted::indexOf(name);
+    if (i >= 0) deleted::splice(i, 1);
+  }
   return wrapper;
 }
 
@@ -275,30 +292,21 @@ function makeGlobalWrapper() {
 function makeComponentUtils() {
   const source = bridge.mode === INJECT_CONTENT && global;
   return {
-    cloneInto: {
-      value: source.cloneInto || assign(
-        (obj) => obj,
-        propertyToString,
-      ),
-    },
-    createObjectIn: {
-      value: source.createObjectIn || assign(
-        (targetScope, { defineAs } = {}) => {
-          const obj = {};
-          if (defineAs) targetScope[defineAs] = obj;
-          return obj;
-        },
-        propertyToString,
-      ),
-    },
-    exportFunction: {
-      value: source.exportFunction || assign(
-        (func, targetScope, { defineAs } = {}) => {
-          if (defineAs) targetScope[defineAs] = func;
-          return func;
-        },
-        propertyToString,
-      ),
-    },
+    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;
+      },
+    ),
   };
 }

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

@@ -14,13 +14,9 @@ import './tabs';
 export default function initialize(
   webId,
   contentId,
-  ua,
-  isFirefox,
   invokeHost,
 ) {
   let invokeGuest;
-  bridge.ua = ua;
-  bridge.isFirefox = isFirefox;
   if (invokeHost) {
     bridge.mode = INJECT_CONTENT;
     bridge.post = (cmd, data) => invokeHost({ cmd, data }, INJECT_CONTENT);
@@ -30,6 +26,11 @@ export default function initialize(
   } else {
     bridge.mode = INJECT_PAGE;
     bridge.post = bindEvents(webId, contentId, bridge.onHandle);
+    bridge.addHandlers({
+      Ping() {
+        bridge.post('Pong');
+      },
+    });
   }
   document.addEventListener('DOMContentLoaded', async () => {
     store.state = 1;

+ 35 - 32
src/injected/web/load-scripts.js

@@ -1,14 +1,16 @@
 import { getUniqId } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
-import { attachFunction } from '../utils';
 import {
-  filter, map, join, defineProperty, Boolean, Promise, setTimeout, log, noop,
+  filter, map, defineProperty, describeProperty, Boolean, Promise, setTimeout, log, noop,
+  remove,
 } from '../utils/helpers';
 import bridge from './bridge';
 import store from './store';
 import { deletePropsCache, wrapGM } from './gm-wrapper';
 
 const { concat } = Array.prototype;
+const { document } = global;
+const { get: getCurrentScript } = describeProperty(Document.prototype, 'currentScript');
 
 bridge.addHandlers({
   LoadScripts(data) {
@@ -16,6 +18,8 @@ bridge.addHandlers({
     const start = [];
     const idle = [];
     const end = [];
+    bridge.isFirefox = data.isFirefox;
+    bridge.ua = data.ua;
     bridge.version = data.version;
     if ([
       'greasyfork.org',
@@ -54,46 +58,54 @@ bridge.addHandlers({
       const pathMap = script.custom.pathMap || {};
       const requireKeys = script.meta.require || [];
       const requires = requireKeys::map(key => data.require[pathMap[key] || key])::filter(Boolean);
-      const code = data.code[script.props.id] || '';
-      const { gm, thisObj, keys } = wrapGM(script, code, data.cache, injectInto);
+      const requiresSlices = []::concat(...requires::map(req => [req, '\n;']));
+      const scriptId = script.props.id;
+      const code = data.code[scriptId] || '';
+      const thisObj = wrapGM(script, code, data.cache, injectInto);
       const id = getUniqId('VMin');
-      const fnId = getUniqId('VMfn');
       const codeSlices = [
-        `function(${
-          keys::join(',')
-        }){${
+        `(function(){${
           isFirefoxContentMode
             ? 'try{'
             : ''
-        }${
-          keys::map(name => `this["${name}"]=${name};`)::join('')
-        }with(this){((define,module,exports)=>{`,
+        // hiding module interface from @require'd scripts so they don't mistakenly use it
+        }with(this)((define,module,exports)=>{`,
         // 1. trying to avoid string concatenation of potentially huge code slices
         // 2. adding `;` on a new line in case some required script ends with a line comment
-        ...[]::concat(...requires::map(req => [req, '\n;'])),
-        '(()=>{',
+        ...requiresSlices,
+        // 3. adding a nested IIFE to support 'use strict' in the code when there are @requires
+        ...requiresSlices.length ? ['(()=>{'] : [],
         code,
         // adding a new line in case the code ends with a line comment
-        `\n})()})()}${
+        `\n${
+          requiresSlices.length ? '})()' : ''
+        }})()${
           isFirefoxContentMode
             ? '}catch(e){console.error(e)}'
             : ''
-        }}`,
+        }}).call(${id})`,
       ];
-      const name = script.custom.name || script.meta.name || script.props.id;
-      const args = keys::map(key => gm[key]);
-      attachFunction(fnId, () => {
-        const func = window[id];
-        if (func) runCode(name, func, args, thisObj);
+      exposeThisObj(thisObj, id, process.env.DEBUG && script);
+      return [codeSlices, bridge.mode, scriptId, script.meta.name];
+    }
+    function exposeThisObj(thisObj, id, script) {
+      defineProperty(window, id, {
+        configurable: true,
+        get() {
+          if (process.env.DEBUG) {
+            log('info', [bridge.mode], script.custom.name || script.meta.name || script.props.id);
+          }
+          const el = document::getCurrentScript();
+          if (el) el::remove();
+          delete window[id];
+          return thisObj;
+        },
       });
-      return [id, codeSlices, fnId, bridge.mode, script.props.id, script.meta.name];
     }
-
     function run(list) {
       bridge.post('InjectMulti', list::map(buildCode));
       list.length = 0;
     }
-
     async function runIdle() {
       for (const script of idle) {
         bridge.post('Inject', buildCode(script));
@@ -105,15 +117,6 @@ bridge.addHandlers({
   },
 });
 
-function runCode(name, func, args, thisObj) {
-  if (process.env.DEBUG) {
-    log('info', [bridge.mode], name);
-  }
-  func.apply(thisObj, args);
-}
-
-// polyfills for Firefox's Components.utils functions exposed to userscripts
-
 function exposeVM() {
   const Violentmonkey = {};
   const checking = {};