Browse Source

fix: move vulnerable web API usage to content mode

tophf 4 years ago
parent
commit
0eee17dcaa

+ 1 - 0
.eslintignore

@@ -6,5 +6,6 @@
 !scripts/plaid.conf.js
 !scripts/webpack.conf.js
 !scripts/webpack-protect-bootstrap-plugin.js
+!scripts/webpack-util.js
 !gulpfile.js
 src/public/**

+ 16 - 7
.eslintrc.js

@@ -1,6 +1,10 @@
-const acorn = require('acorn');
+const { readGlobalsFile } = require('./scripts/webpack-util');
+
 const FILES_INJECTED = [`src/injected/**/*.js`];
-const FILES_CONTENT = [`src/injected/content/**/*.js`];
+const FILES_CONTENT = [
+  'src/injected/*.js',
+  'src/injected/content/**/*.js',
+];
 const FILES_WEB = [`src/injected/web/**/*.js`];
   // some functions are used by `injected`
 const FILES_SHARED = [
@@ -108,14 +112,19 @@ module.exports = {
   },
 };
 
-function getGlobals(fileName) {
-  const text = require('fs').readFileSync(fileName, { encoding: 'utf8' });
+function getGlobals(filename) {
   const res = {};
-  const tree = acorn.parse(text, { ecmaVersion: 2018, sourceType: 'module' });
-  tree.body.forEach(body => {
+  const { ast } = readGlobalsFile(filename, { ast: true });
+  ast.program.body.forEach(body => {
     const { declarations } = body.declaration || body;
     if (!declarations) return;
-    declarations.forEach(function processId({ id: { left, properties, name = left && left.name } }) {
+    declarations.forEach(function processId({
+      id: {
+        left,
+        properties,
+        name = left && left.name,
+      },
+    }) {
       if (name) {
         // const NAME = whatever
         res[name] = false;

+ 69 - 0
scripts/webpack-util.js

@@ -0,0 +1,69 @@
+const fs = require('fs');
+const babelCore = require('@babel/core');
+const WrapperWebpackPlugin = require('wrapper-webpack-plugin');
+
+// {entryName: path}
+const entryGlobals = {
+  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.
+ */
+function addWrapperWithGlobals(name, config, defsObj, callback) {
+  config.module.rules.push({
+    test: new RegExp(`/${name}/.*?\\.js$`.replace(/\//g, /[/\\]/.source)),
+    use: [{
+      loader: './scripts/fake-dep-loader.js',
+      options: { files: entryGlobals[name] },
+    }],
+  });
+  const defsRe = new RegExp(`\\b(${
+    Object.keys(defsObj)
+    .join('|')
+    .replace(/\./g, '\\.')
+  })\\b`, 'g');
+  const reader = () => (
+    entryGlobals[name]
+    .map(path => readGlobalsFile(path))
+    .join('\n')
+    .replace(defsRe, s => defsObj[s])
+  );
+  config.plugins.push(new WrapperWebpackPlugin(callback(reader)));
+}
+
+function getUniqIdB64() {
+  return Buffer.from(
+    new Uint32Array(2)
+    .map(() => Math.random() * (2 ** 32))
+    .buffer,
+  ).toString('base64');
+}
+
+function readGlobalsFile(filename, babelOpts = {}) {
+  const { ast, code = !ast } = babelOpts;
+  const src = fs.readFileSync(filename, { encoding: 'utf8' })
+  .replace(/\bexport\s+(function\s+(\w+))/g, 'const $2 = $1')
+  .replace(/\bexport\s+(?=(const|let)\s)/g, '');
+  const res = babelCore.transformSync(src, {
+    ...babelOpts,
+    ast,
+    code,
+    filename,
+  });
+  return ast ? res : res.code;
+}
+
+exports.addWrapperWithGlobals = addWrapperWithGlobals;
+exports.getUniqIdB64 = getUniqIdB64;
+exports.readGlobalsFile = readGlobalsFile;

+ 5 - 48
scripts/webpack.conf.js

@@ -1,24 +1,17 @@
 const { modifyWebpackConfig, shallowMerge, defaultOptions } = require('@gera2ld/plaid');
 const { isProd } = require('@gera2ld/plaid/util');
-const fs = require('fs');
 const webpack = require('webpack');
-const WrapperWebpackPlugin = require('wrapper-webpack-plugin');
 const HTMLInlineCSSWebpackPlugin = isProd && require('html-inline-css-webpack-plugin').default;
 const TerserPlugin = isProd && require('terser-webpack-plugin');
 const deepmerge = isProd && require('deepmerge');
 const { ListBackgroundScriptsPlugin } = require('./manifest-helper');
+const { addWrapperWithGlobals, getUniqIdB64 } = require('./webpack-util');
 const ProtectWebpackBootstrapPlugin = require('./webpack-protect-bootstrap-plugin');
 const projectConfig = require('./plaid.conf');
 const mergedConfig = shallowMerge(defaultOptions, projectConfig);
 
 // 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 INIT_FUNC_NAME = `Violentmonkey:${getUniqIdB64()}`;
 const VAULT_ID = '__VAULT_ID__';
 const HANDSHAKE_ID = '__HANDSHAKE_ID__';
 // eslint-disable-next-line import/no-dynamic-require
@@ -88,47 +81,11 @@ const defsObj = {
   'process.env.HANDSHAKE_ID': HANDSHAKE_ID,
   'process.env.HANDSHAKE_ACK': '1',
 };
-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/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) => {
-  config.module.rules.push({
-    test: new RegExp(`/${name}/.*?\\.js$`.replace(/\//g, /[/\\]/.source)),
-    use: [{
-      loader: './scripts/fake-dep-loader.js',
-      options: { files: entryGlobals[name] },
-    }],
-  });
-  const reader = () => (
-    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)));
-};
 
 const modify = (page, entry, init) => modifyWebpackConfig(
   (config) => {
@@ -154,7 +111,7 @@ const modify = (page, entry, init) => modifyWebpackConfig(
 
 module.exports = Promise.all([
   modify((config) => {
-    addWrapper(config, 'common', getGlobals => ({
+    addWrapperWithGlobals('common', config, defsObj, getGlobals => ({
       header: () => `{ ${getGlobals()}`,
       footer: '}',
       test: /^(?!injected|public).*\.js$/,
@@ -181,7 +138,7 @@ module.exports = Promise.all([
 
   modify('injected', './src/injected', (config) => {
     config.plugins.push(new ProtectWebpackBootstrapPlugin());
-    addWrapper(config, 'injected/content', getGlobals => ({
+    addWrapperWithGlobals('injected/content', config, defsObj, getGlobals => ({
       header: () => `${skipReinjectionHeader} { ${getGlobals()}`,
       footer: '}',
     }));
@@ -191,7 +148,7 @@ module.exports = Promise.all([
     // TODO: replace WebPack's Object.*, .call(), .apply() with safe calls
     config.output.libraryTarget = 'commonjs2';
     config.plugins.push(new ProtectWebpackBootstrapPlugin());
-    addWrapper(config, 'injected/web', getGlobals => ({
+    addWrapperWithGlobals('injected/web', config, defsObj, getGlobals => ({
       header: () => `${skipReinjectionHeader}
         window['${INIT_FUNC_NAME}'] = function (IS_FIREFOX,${HANDSHAKE_ID},${VAULT_ID}) {
           const module = { __proto__: null };

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

@@ -279,7 +279,7 @@ export async function getScriptsByURL(url, isTop) {
   /** @namespace VMScriptByUrlData */
   const [envStart, envDelayed] = [0, 1].map(() => ({
     ids: [],
-    /** @type {VMInjectedScript[]} */
+    /** @type {(VMScript & VMInjectedScript)[]} */
     scripts: [],
     [ENV_CACHE_KEYS]: [],
     [ENV_REQ_KEYS]: [],

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

@@ -190,6 +190,7 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, isLate) {
   Object.assign(inject, {
     injectInto,
     scripts,
+    cache: data.cache,
     feedId: {
       cacheKey, // InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected
       envKey, // InjectionFeedback cache key for envDelayed
@@ -197,7 +198,6 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, isLate) {
     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,
       ua,
     },
   });

+ 38 - 31
src/background/utils/requests.js

@@ -29,7 +29,7 @@ Object.assign(commands, {
       xhr: new XMLHttpRequest(),
     };
     (tabRequests[tabId] || (tabRequests[tabId] = {}))[id] = 1;
-    httpRequest(opts, src, res => (
+    httpRequest(opts, src, res => requests[id] && (
       sendTabCmd(tabId, 'HttpRequested', res, { frameId })
     ));
   },
@@ -227,13 +227,12 @@ function xhrCallbackWrapper(req) {
   };
   return (evt) => {
     const type = evt.type;
-    if (type === 'loadend') clearRequest(req);
-    if (!req.cb) return;
     if (!contentType) {
       contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream';
     }
     if (xhr.response !== response) {
       response = xhr.response;
+      sent = false;
       try {
         responseText = xhr.responseText;
         if (responseText === response) responseText = ['same'];
@@ -248,36 +247,44 @@ function xhrCallbackWrapper(req) {
     const shouldNotify = req.eventsToNotify.includes(type);
     // only send response when XHR is complete
     const shouldSendResponse = xhr.readyState === 4 && shouldNotify && !sent;
-    lastPromise = lastPromise
-    .then(() => shouldSendResponse && numChunks && getChunk(response, 0))
-    .then(chunk => req.cb({
-      blobbed,
-      chunked,
-      contentType,
-      dataSize,
-      id,
-      numChunks,
-      type,
-      data: shouldNotify && {
-        finalUrl: xhr.responseURL,
-        ...getResponseHeaders(),
-        ...objectPick(xhr, ['readyState', 'status', 'statusText']),
-        ...shouldSendResponse && {
-          response: chunk || response,
-          responseText,
+    lastPromise = lastPromise.then(async () => {
+      await req.cb({
+        blobbed,
+        chunked,
+        contentType,
+        dataSize,
+        id,
+        numChunks,
+        type,
+        data: shouldNotify && {
+          finalUrl: xhr.responseURL,
+          ...getResponseHeaders(),
+          ...objectPick(xhr, ['readyState', 'status', 'statusText']),
+          ...('loaded' in evt) && objectPick(evt, ['lengthComputable', 'loaded', 'total']),
+          response: shouldSendResponse
+            ? numChunks && await getChunk(response, 0) || response
+            : null,
+          responseText: shouldSendResponse
+            ? responseText
+            : null,
         },
-        ...('loaded' in evt) && objectPick(evt, ['lengthComputable', 'loaded', 'total']),
-      },
-    }));
-    if (shouldSendResponse) {
-      sent = true;
-      for (let i = 1; i < numChunks; i += 1) {
-        const last = i === numChunks - 1;
-        lastPromise = lastPromise
-        .then(() => getChunk(response, i)) // eslint-disable-line no-loop-func
-        .then(data => req.cb({ id, chunk: { data, i, last } }));
+      });
+      if (shouldSendResponse) {
+        for (let i = 1; i < numChunks; i += 1) {
+          await req.cb({
+            id,
+            chunk: {
+              i,
+              data: await getChunk(response, i),
+              last: i + 1 === numChunks,
+            },
+          });
+        }
       }
-    }
+      if (type === 'loadend') {
+        clearRequest(req);
+      }
+    });
   };
 }
 

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

@@ -6,7 +6,6 @@
  * `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 _() {

+ 46 - 7
src/injected/content/bridge.js

@@ -1,9 +1,7 @@
 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>} */
+const allowed = createNullObj();
 const handlers = createNullObj();
 const bgHandlers = createNullObj();
 const onScripts = [];
@@ -14,6 +12,10 @@ const assignHandlers = (dest, src, force) => {
     onScripts.push(() => assign(dest, src));
   }
 };
+const allowCmd = (cmd, dataKey) => {
+  (allowed[cmd] || (allowed[cmd] = createNullObj()))[dataKey] = true;
+};
+
 const bridge = {
   __proto__: null,
   ids: [], // all ids including the disabled ones for SetPopup
@@ -22,8 +24,15 @@ const bridge = {
   /** @type Number[] */
   invokableIds: [],
   failedIds: [],
+  cache: createNullObj(),
+  pathMaps: createNullObj(),
   onScripts,
-  /** Without `force` handlers will be added only when userscripts are about to be injected. */
+  allowCmd,
+  /**
+   * Without `force` handlers will be added only when userscripts are about to be injected.
+   * @param {Object.<string, MessageFromGuestHandler>} obj
+   * @param {boolean} [force]
+   */
   addHandlers(obj, force) {
     assignHandlers(handlers, obj, force);
   },
@@ -32,13 +41,43 @@ const bridge = {
   addBackgroundHandlers(obj, force) {
     assignHandlers(bgHandlers, obj, force);
   },
-  allow(cmd, dataKey) {
-    (allow[cmd] || (allow[cmd] = createNullObj()))[dataKey] = true;
+  /**
+   * @param {VMInjectedScript | VMScript} script
+   */
+  allowScript({ dataKey, meta }) {
+    allowCmd('Run', dataKey);
+    meta.grant::forEach(grant => {
+      const gm = /^GM[._]/::regexpTest(grant) && grant::slice(3);
+      if (grant === 'GM_xmlhttpRequest' || grant === 'GM.xmlHttpRequest' || gm === 'download') {
+        allowCmd('AbortRequest', dataKey);
+        allowCmd('HttpRequest', dataKey);
+      } else if (grant === 'window.close') {
+        allowCmd('TabClose', dataKey);
+      } else if (grant === 'window.focus') {
+        allowCmd('TabFocus', dataKey);
+      } else if (gm === 'addElement' || gm === 'addStyle') {
+        allowCmd('AddElement', dataKey);
+      } else if (gm === 'setValue' || gm === 'deleteValue') {
+        allowCmd('UpdateValue', dataKey);
+      } else if (gm === 'notification') {
+        allowCmd('Notification', dataKey);
+        allowCmd('RemoveNotification', dataKey);
+      } else if (gm === 'openInTab') {
+        allowCmd('TabOpen', dataKey);
+        allowCmd('TabClose', dataKey);
+      } else if (gm === 'registerMenuCommand') {
+        allowCmd('RegisterMenu', dataKey);
+      } else if (gm === 'setClipboard') {
+        allowCmd('SetClipboard', dataKey);
+      } else if (gm === 'unregisterMenuCommand') {
+        allowCmd('UnregisterMenu', dataKey);
+      }
+    });
   },
   // realm is provided when called directly via invokeHost
   async onHandle({ cmd, data, dataKey, node }, realm) {
     const handle = handlers[cmd];
-    if (!handle || !allow[cmd]?.[dataKey]) {
+    if (!handle || !allowed[cmd]?.[dataKey]) {
       throw new ErrorSafe(`[Violentmonkey] Invalid command: "${cmd}" on ${global.location.host}`);
     }
     const callbackId = data && getOwnProp(data, CALLBACK_ID);

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

@@ -1,4 +1,3 @@
-import { log } from '../util';
 import bridge from './bridge';
 
 bridge.onScripts.push(() => {

+ 78 - 0
src/injected/content/gm-api-content.js

@@ -0,0 +1,78 @@
+import { sendCmd } from '#/common';
+import bridge from './bridge';
+import { decodeResource, elemByTag, makeElem } from './util-content';
+
+const menus = createNullObj();
+let setPopupThrottle;
+let isPopupShown;
+
+bridge.onScripts.push(injection => {
+  isPopupShown = injection.isPopupShown;
+});
+
+bridge.addBackgroundHandlers({
+  PopupShown(state) {
+    isPopupShown = state;
+    sendSetPopup();
+  },
+}, true);
+
+bridge.addHandlers({
+  /** @this {Node} */
+  AddElement({ tag, attrs, cbId }, realm) {
+    let el;
+    let res;
+    try {
+      const parent = this
+        || /^(script|style|link|meta)$/i::regexpTest(tag) && elemByTag('head')
+        || elemByTag('body')
+        || elemByTag('*');
+      el = makeElem(tag, attrs);
+      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
+      res = [`${e}`, e.stack];
+    }
+    bridge.post('Callback', { id: cbId, data: res }, realm, el);
+  },
+
+  GetResource({ id, isBlob, key }) {
+    const path = bridge.pathMaps[id]?.[key] || key;
+    const raw = bridge.cache[path];
+    return raw ? decodeResource(raw, isBlob) : true;
+  },
+
+  RegisterMenu({ id, cap }) {
+    if (IS_TOP) {
+      const commandMap = menus[id] || (menus[id] = createNullObj());
+      commandMap[cap] = 1;
+      sendSetPopup(true);
+    }
+  },
+
+  UnregisterMenu({ id, cap }) {
+    if (IS_TOP) {
+      delete menus[id]?.[cap];
+      sendSetPopup(true);
+    }
+  },
+});
+
+export async function sendSetPopup(isDelayed) {
+  if (isPopupShown) {
+    if (isDelayed) {
+      if (setPopupThrottle) return;
+      // Preventing flicker in popup when scripts re-register menus
+      setPopupThrottle = sendCmd('SetTimeout', 0);
+      await setPopupThrottle;
+      setPopupThrottle = null;
+    }
+    sendCmd('SetPopup', { menus, __proto__: null }::pickIntoThis(bridge, [
+      'ids',
+      'injectInto',
+      'runningIds',
+      'failedIds',
+    ]));
+  }
+}

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

@@ -2,23 +2,17 @@ import { isEmpty, sendCmd } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
 import bridge from './bridge';
 import './clipboard';
+import { sendSetPopup } from './gm-api-content';
 import { injectPageSandbox, injectScripts } from './inject';
 import './notifications';
 import './requests';
 import './tabs';
-import { elemByTag } from './util-content';
-import { NS_HTML, createNullObj, getUniqIdSafe, promiseResolve } from '../util';
 
 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
 (async () => {
@@ -38,35 +32,27 @@ let pendingSetPopup;
   const data = IS_FIREFOX && Event[PROTO].composedPath
     ? await getDataFF(dataPromise)
     : await dataPromise;
-  const { allow } = bridge;
-  ids = data.ids;
-  injectInto = data.injectInto;
-  bridge.ids = ids;
-  bridge.injectInto = injectInto;
-  isPopupShown = data.isPopupShown;
+  const { allowCmd } = bridge;
+  allowCmd('VaultId', contentId);
+  bridge::pickIntoThis(data, [
+    'ids',
+    'injectInto',
+  ]);
   if (data.expose) {
-    allow('GetScriptVer', contentId);
+    allowCmd('GetScriptVer', contentId);
     bridge.addHandlers({ GetScriptVer: true }, true);
     bridge.post('Expose');
   }
   if (data.scripts) {
-    bridge.onScripts.forEach(fn => fn());
-    allow('SetTimeout', contentId);
-    if (IS_FIREFOX) allow('InjectList', contentId);
+    bridge.onScripts.forEach(fn => fn(data));
+    allowCmd('SetTimeout', contentId);
+    if (IS_FIREFOX) allowCmd('InjectList', contentId);
     await injectScripts(contentId, webId, data);
   }
-  allow('VaultId', contentId);
   bridge.onScripts = null;
   sendSetPopup();
 })().catch(IS_FIREFOX && console.error); // Firefox can't show exceptions in content scripts
 
-bridge.addBackgroundHandlers({
-  PopupShown(state) {
-    isPopupShown = state;
-    sendSetPopup();
-  },
-}, true);
-
 bridge.addBackgroundHandlers({
   Command(data) {
     const realm = invokableIds::includes(data.id) && INJECT_CONTENT;
@@ -84,46 +70,9 @@ bridge.addBackgroundHandlers({
 });
 
 bridge.addHandlers({
-  RegisterMenu({ id, cap }) {
-    if (IS_TOP) {
-      const commandMap = menus[id] || (menus[id] = createNullObj());
-      commandMap[cap] = 1;
-      sendSetPopup(true);
-    }
-  },
-  UnregisterMenu({ id, cap }) {
-    if (IS_TOP) {
-      delete menus[id]?.[cap];
-      sendSetPopup(true);
-    }
-  },
-  /** @this {Node} */
-  AddElement({ tag, attrs, cbId }, realm) {
-    let el;
-    let res;
-    try {
-      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]);
-        });
-      }
-      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
-      res = [`${e}`, e.stack];
-    }
-    bridge.post('Callback', { id: cbId, data: res }, realm, el);
-  },
   Run(id, realm) {
     runningIds::push(id);
-    ids::push(id);
+    bridge.ids::push(id);
     if (realm === INJECT_CONTENT) {
       invokableIds::push(id);
     }
@@ -155,25 +104,6 @@ function throttledSetBadge() {
   }
 }
 
-async function sendSetPopup(isDelayed) {
-  if (isPopupShown) {
-    if (isDelayed) {
-      if (pendingSetPopup) return;
-      // Preventing flicker in popup when scripts re-register menus
-      pendingSetPopup = sendCmd('SetTimeout', 0);
-      await pendingSetPopup;
-      pendingSetPopup = null;
-    }
-    sendCmd('SetPopup', {
-      ids,
-      injectInto,
-      menus,
-      runningIds,
-      failedIds: bridge.failedIds,
-    });
-  }
-}
-
 async function getDataFF(viaMessaging) {
   // In Firefox we set data on global `this` which is not equal to `window`
   const data = global.vmData || await PromiseSafe.race([

+ 8 - 11
src/injected/content/inject.js

@@ -2,11 +2,8 @@ import { INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser } from '#/common/c
 import { sendCmd } from '#/common';
 import { forEachKey } from '#/common/object';
 import bridge from './bridge';
-import { allowCommands, appendToRoot, onElement } from './util-content';
-import {
-  NS_HTML, bindEvents, fireBridgeEvent,
-  getUniqIdSafe, isSameOriginWindow, log,
-} from '../util';
+import { appendToRoot, onElement } from './util-content';
+import { bindEvents, fireBridgeEvent } from '../util';
 
 const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
 const VAULT_SEED_NAME = INIT_FUNC_NAME + process.env.VAULT_ID_NAME;
@@ -111,16 +108,19 @@ export async function injectScripts(contentId, webId, data) {
       info,
     },
   };
+  assign(bridge.cache, data.cache);
   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) {
+      const { pathMap } = script.custom;
       const realmData = realms[realm];
       realmData.lists[script.runAt].push(script); // 'start' or 'body' per getScriptsByURL()
       realmData.is = true;
-      allowCommands(script);
+      if (pathMap) bridge.pathMaps[id] = pathMap;
+      bridge.allowScript(script);
     } else {
       bridge.failedIds.push(id);
     }
@@ -158,9 +158,7 @@ export async function injectScripts(contentId, webId, data) {
 }
 
 async function injectDelayedScripts(contentId, webId, { cache, scripts }, getReadyState) {
-  realms::forEachKey(r => {
-    realms[r].info.cache = cache;
-  });
+  assign(bridge.cache, cache);
   let needsInvoker;
   scripts::forEach(script => {
     const { code, runAt } = script;
@@ -182,7 +180,7 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }, getRea
   if (needsInvoker && contentId) {
     setupContentInvoker(contentId, webId);
   }
-  scripts::forEach(allowCommands);
+  scripts::forEach(bridge.allowScript);
   injectAll('end');
   injectAll('idle');
 }
@@ -216,7 +214,6 @@ function injectAll(runAt) {
     const { info } = realmData;
     if (items.length) {
       bridge.post('ScriptData', { info, items, runAt }, realm);
-      info.cache = null;
       if (realm === INJECT_PAGE && !IS_FIREFOX) {
         injectList(runAt);
       }

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

@@ -1,6 +1,5 @@
 import { sendCmd } from '#/common';
 import bridge from './bridge';
-import { createNullObj } from '../util';
 
 const notifications = createNullObj();
 

+ 103 - 31
src/injected/content/requests.js

@@ -1,67 +1,139 @@
-import { sendCmd } from '#/common';
+import { isPromise, sendCmd } from '#/common';
 import bridge from './bridge';
-import { createNullObj } from '../util';
+import { getFullUrl, makeElem } from './util-content';
 
-const { fetch } = global;
-const { arrayBuffer: getArrayBuffer, blob: getBlob } = Response[PROTO];
+const {
+  fetch: fetchSafe,
+} = global;
+const { arrayBuffer: getArrayBuffer, blob: getBlob } = ResponseProto;
+const { createObjectURL, revokeObjectURL } = URL;
 
 const requests = createNullObj();
+let downloadChain = promiseResolve();
 
 bridge.addHandlers({
-  HttpRequest(opts, realm) {
-    requests[opts.id] = {
+  HttpRequest(msg, realm) {
+    requests[msg.id] = {
+      __proto__: null,
       realm,
-      eventsToNotify: opts.eventsToNotify,
-      wantsBlob: opts.wantsBlob,
-    };
-    sendCmd('HttpRequest', opts);
+    }::pickIntoThis(msg, [
+      'eventsToNotify',
+      'fileName',
+      'wantsBlob',
+    ]);
+    msg.url = getFullUrl(msg.url);
+    sendCmd('HttpRequest', msg);
   },
   AbortRequest: true,
 });
 
 bridge.addBackgroundHandlers({
   async HttpRequested(msg) {
-    const { blobbed, id, numChunks, type } = msg;
+    const { id } = msg;
     const req = requests[id];
     if (!req) return;
+    const { chunk } = msg;
+    if (chunk) {
+      receiveChunk(req, chunk);
+      return;
+    }
+    const { blobbed, data, chunked, type } = msg;
     const isLoadEnd = type === 'loadend';
     // only CONTENT realm can read blobs from an extension:// URL
-    const url = blobbed
-      && !req.response
+    const response = data
       && req.eventsToNotify::includes(type)
-      && msg.data.response;
+      && data.response;
     // messages will come while blob is fetched so we'll temporarily store the Promise
-    if (url) {
-      req.response = importBlob(url, req);
+    const importing = response && (blobbed || chunked);
+    if (importing) {
+      req.bin = blobbed
+        ? importBlob(req, response)
+        : receiveAllChunks(req, msg);
     }
     // ...which can be awaited in these subsequent messages
-    if (req.response?.then) {
-      req.response = await req.response;
-    }
-    // ...and make sure loadend's bridge.post() runs last
-    if (isLoadEnd && blobbed) {
-      await 0;
+    if (isPromise(req.bin)) {
+      req.bin = await req.bin;
     }
-    if (url) {
-      msg.data.response = req.response;
-    }
-    bridge.post('HttpRequested', msg, req.realm);
     // If the user in incognito supplied only `onloadend` then it arrives first, followed by chunks
     if (isLoadEnd) {
+      // loadend's bridge.post() should run last
+      await 0;
       req.gotLoadEnd = true;
-      req.gotChunks = req.gotChunks || (numChunks || 0) <= 1;
-    } else if (msg.chunk?.last) {
-      req.gotChunks = true;
     }
     // If the user supplied any event before `loadend`, all chunks finish before `loadend` arrives
+    if ((msg.numChunks || 0) <= 1) {
+      req.gotChunks = true;
+    }
+    if (importing) {
+      data.response = req.bin;
+    }
+    const fileName = type === 'load' && req.bin && req.fileName;
+    if (fileName) {
+      req.fileName = '';
+      await downloadBlob(req.bin, fileName);
+    }
+    bridge.post('HttpRequested', msg, req.realm);
     if (req.gotLoadEnd && req.gotChunks) {
       delete requests[id];
     }
   },
 });
 
-async function importBlob(url, { wantsBlob }) {
-  const data = await (await fetch(url))::(wantsBlob ? getBlob : getArrayBuffer)();
+async function importBlob(req, url) {
+  const data = await (await fetchSafe(url))::(req.wantsBlob ? getBlob : getArrayBuffer)();
   sendCmd('RevokeBlob', url);
   return data;
 }
+
+function downloadBlob(blob, fileName) {
+  const url = createObjectURL(blob);
+  const a = makeElem('a', {
+    href: url,
+    download: fileName,
+  });
+  const res = downloadChain::then(() => {
+    a::fire(new MouseEventSafe('click'));
+    revokeBlobAfterTimeout(url);
+  });
+  // Frequent downloads are ignored in Chrome and possibly other browsers
+  downloadChain = res::then(sendCmd('SetTimeout', 150));
+  return res;
+}
+
+async function revokeBlobAfterTimeout(url) {
+  await sendCmd('SetTimeout', 3000);
+  revokeObjectURL(url);
+}
+
+/** ArrayBuffer/Blob in Chrome incognito is transferred in string chunks */
+function receiveAllChunks(req, msg) {
+  req::pickIntoThis(msg, ['dataSize', 'contentType']);
+  req.chunks = [msg.data.response];
+  return msg.numChunks > 1
+    ? new PromiseSafe(resolve => { req.resolve = resolve; })
+    : decodeChunks(req);
+}
+
+function receiveChunk(req, { data, i, last }) {
+  setOwnProp(req.chunks, i, data);
+  if (last) {
+    req.gotChunks = true;
+    req.resolve(decodeChunks(req));
+    delete req.resolve;
+  }
+}
+
+function decodeChunks(req) {
+  const arr = new Uint8ArraySafe(req.dataSize);
+  let dstIndex = 0;
+  req.chunks::forEach(chunk => {
+    chunk = atobSafe(chunk);
+    for (let len = chunk.length, i = 0; i < len; i += 1, dstIndex += 1) {
+      arr[dstIndex] = chunk::charCodeAt(i);
+    }
+  });
+  delete req.chunks;
+  return req.wantsBlob
+    ? new BlobSafe([arr], { type: req.contentType })
+    : arr.buffer;
+}

+ 8 - 2
src/injected/content/safe-globals-content.js

@@ -3,26 +3,30 @@
 /**
  * `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 {
+  Blob: BlobSafe,
   CustomEvent: CustomEventSafe,
   Error: ErrorSafe,
   MouseEvent: MouseEventSafe,
   Object, // for minification and guarding webpack Object(import) calls
   Promise: PromiseSafe,
+  TextDecoder: TextDecoderSafe,
+  Uint8Array: Uint8ArraySafe,
+  atob: atobSafe,
   addEventListener: on,
   dispatchEvent: fire,
   removeEventListener: off,
 } = global;
+export const ResponseProto = Response[PROTO];
 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 { charCodeAt, indexOf: stringIndexOf, slice } = '';
 export const { append, appendChild, remove, setAttribute } = Element[PROTO];
 export const {
   assign,
@@ -33,6 +37,8 @@ export const {
 export const { random: mathRandom } = Math;
 export const regexpTest = RegExp[PROTO].test;
 export const { toStringTag } = Symbol; // used by ProtectWebpackBootstrapPlugin
+export const { decode: tdDecode } = TextDecoderSafe[PROTO];
+export const { get: getHref } = describeProperty(HTMLAnchorElement[PROTO], 'href');
 export const getDetail = describeProperty(CustomEventSafe[PROTO], 'detail').get;
 export const getRelatedTarget = describeProperty(MouseEventSafe[PROTO], 'relatedTarget').get;
 export const logging = assign({ __proto__: null }, console);

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

@@ -1,6 +1,5 @@
 import { sendCmd } from '#/common';
 import bridge from './bridge';
-import { createNullObj } from '../util';
 
 const tabIds = createNullObj();
 const tabKeys = createNullObj();

+ 34 - 40
src/injected/content/util-content.js

@@ -1,43 +1,3 @@
-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. */
@@ -70,3 +30,37 @@ export const onElement = (tag, cb, arg) => new PromiseSafe(resolve => {
     observer.observe(document, { childList: true, subtree: true });
   }
 });
+
+export const makeElem = (tag, attrs) => {
+  const 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]);
+    });
+  }
+  return el;
+};
+
+export const getFullUrl = url => (
+  makeElem('a', { href: url })::getHref()
+);
+
+export const decodeResource = (raw, isBlob) => {
+  let res;
+  const pos = raw::stringIndexOf(',');
+  const bin = atobSafe(pos < 0 ? raw : raw::slice(pos + 1));
+  if (isBlob || /[\x80-\xFF]/::regexpTest(bin)) {
+    const len = bin.length;
+    const bytes = new Uint8ArraySafe(len);
+    for (let i = 0; i < len; i += 1) {
+      bytes[i] = bin::charCodeAt(i);
+    }
+    res = isBlob
+      ? new BlobSafe([bytes], { type: pos < 0 ? '' : raw::slice(0, pos) })
+      : new TextDecoderSafe()::tdDecode(bytes);
+  } else { // pure ASCII
+    res = bin;
+  }
+  return res;
+};

+ 8 - 8
src/injected/index.js

@@ -3,25 +3,25 @@ import { sendCmd } from '#/common';
 import './content';
 
 // Script installation in Firefox as it does not support `onBeforeRequest` for `file:`
-if (IS_FIREFOX && IS_TOP
-&& global.location.protocol === 'file:'
-&& global.location.pathname.endsWith('.user.js')) {
+const url = IS_FIREFOX && IS_TOP && global.location.href;
+if (url
+&& /^file:/::regexpTest(url)
+&& /\.user\.js$/::regexpTest(url)) {
   (async () => {
     const {
       browser,
       fetch,
       history,
       document: { referrer },
-      Response: { [PROTO]: { text: getText } },
-      location: { href: url },
     } = global;
+    const { text: getText } = ResponseProto;
     const fetchOpts = { mode: 'same-origin' };
     const response = await fetch(url, fetchOpts);
-    if (!/javascript|^text\/plain|^$/.test(response.headers.get('content-type') || '')) {
+    if (!/javascript|^text\/plain|^$/::regexpTest(response.headers.get('content-type') || '')) {
       return;
     }
-    let code = await response.text();
-    if (!/==userscript==/i.test(code)) {
+    let code = await response::getText();
+    if (!/==userscript==/i::regexpTest(code)) {
       return;
     }
     await sendCmd('ConfirmInstall', { code, url, from: referrer });

+ 66 - 1
src/injected/safe-globals-injected.js

@@ -4,7 +4,6 @@
  * 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 _() {
@@ -15,3 +14,69 @@ const global = (function _() {
 const { document, window } = global;
 export const PROTO = 'prototype';
 export const IS_TOP = window.top === window;
+export const WINDOW_CLOSE = 'window.close';
+export const WINDOW_FOCUS = 'window.focus';
+export const NS_HTML = 'http://www.w3.org/1999/xhtml';
+export const CALLBACK_ID = '__CBID';
+
+export const getOwnProp = (obj, key) => (
+  obj::hasOwnProperty(key)
+    ? obj[key]
+    : undefined
+);
+
+/** 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 const setOwnProp = (obj, key, value) => (
+  defineProperty(obj, key, {
+    value,
+    configurable: true,
+    enumerable: true,
+    writable: true,
+  })
+);
+
+export const vmOwnFuncToString = () => '[Violentmonkey property]';
+
+/** Using __proto__ because Object.create(null) may be spoofed */
+export const createNullObj = () => ({ __proto__: null });
+
+export const promiseResolve = () => (async () => {})();
+
+export const vmOwnFunc = (func, toString) => (
+  defineProperty(func, 'toString', { value: toString || vmOwnFuncToString })
+);
+
+/** @param {Window} wnd */
+export const isSameOriginWindow = wnd => (
+  describeProperty(wnd.location, 'href').get
+);
+
+// Avoiding the need to safe-guard a bunch of methods so we use just one
+export const getUniqIdSafe = (prefix = 'VM') => `${prefix}${mathRandom()}`;
+
+/** 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);
+};
+
+/**
+ * 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/util/index.js

@@ -1,36 +1,3 @@
-const vmOwnFuncToString = () => '[Violentmonkey property]';
-
-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 vmOwnFunc = (func, toString) => (
-  defineProperty(func, 'toString', { value: toString || vmOwnFuncToString })
-);
-export const getOwnProp = (obj, key) => (
-  obj::hasOwnProperty(key)
-    ? obj[key]
-    : undefined
-);
-/** 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 const setOwnProp = (obj, key, value) => (
-  defineProperty(obj, key, {
-    value,
-    configurable: true,
-    enumerable: true,
-    writable: true,
-  })
-);
-/** @param {Window} wnd */
-export const isSameOriginWindow = wnd => (
-  describeProperty(wnd.location, 'href').get
-);
-// Avoiding the need to safe-guard a bunch of methods so we use just one
-export const getUniqIdSafe = (prefix = 'VM') => `${prefix}${mathRandom()}`;
-
 export const fireBridgeEvent = (eventId, msg, cloneInto) => {
   const detail = cloneInto ? cloneInto(msg, document) : msg;
   const evtMain = new CustomEventSafe(eventId, { detail });
@@ -61,28 +28,3 @@ export const bindEvents = (srcId, destId, bridge, cloneInto) => {
     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);
-};
-
-/**
- * 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;
-}

+ 13 - 6
src/injected/web/bridge.js

@@ -1,5 +1,3 @@
-import { CALLBACK_ID, createNullObj, getUniqIdSafe } from '../util';
-
 const handlers = createNullObj();
 const callbacks = createNullObj();
 /**
@@ -7,7 +5,6 @@ const callbacks = createNullObj();
  */
 const bridge = {
   __proto__: null,
-  cache: createNullObj(),
   callbacks,
   addHandlers(obj) {
     assign(handlers, obj);
@@ -18,11 +15,21 @@ const bridge = {
   },
   send(cmd, data, context, node) {
     return new PromiseSafe(resolve => {
-      const id = getUniqIdSafe();
-      callbacks[id] = resolve;
-      bridge.post(cmd, { [CALLBACK_ID]: id, data }, context, node);
+      postWithCallback(cmd, data, context, node, resolve);
     });
   },
+  syncCall: postWithCallback,
 };
 
+function postWithCallback(cmd, data, context, node, cb, customCallbackId) {
+  const id = getUniqIdSafe();
+  callbacks[id] = cb;
+  if (customCallbackId) {
+    setOwnProp(data, customCallbackId, id);
+  } else {
+    data = { [CALLBACK_ID]: id, data };
+  }
+  bridge.post(cmd, data, context, node);
+}
+
 export default bridge;

+ 7 - 5
src/injected/web/gm-api-wrapper.js

@@ -2,7 +2,6 @@ import bridge from './bridge';
 import { makeGmApi } from './gm-api';
 import { makeGlobalWrapper } from './gm-global-wrapper';
 import { makeComponentUtils } from './util-web';
-import { createNullObj, vmOwnFunc } from '../util';
 
 /** Name in Greasemonkey4 -> name in GM */
 const GM4_ALIAS = {
@@ -21,6 +20,10 @@ const GM4_ASYNC = {
 let gmApi;
 let componentUtils;
 
+/**
+ * @param {VMScript & VMInjectedScript} script
+ * @returns {Object}
+ */
 export function makeGmApiWrapper(script) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
@@ -30,15 +33,14 @@ export function makeGmApiWrapper(script) {
     grant.length = 0;
   }
   const { id } = script.props;
-  const resources = meta.resources || createNullObj();
-  /** @namespace VMInjectedScriptContext */
+  const resources = assign(createNullObj(), meta.resources);
+  /** @namespace VMInjectedScript.Context */
   const context = {
     id,
     script,
     resources,
     dataKey: script.dataKey,
-    pathMap: script.custom.pathMap || createNullObj(),
-    urls: createNullObj(),
+    resCache: createNullObj(),
   };
   const gmInfo = makeGmInfo(script, resources);
   const gm = {

+ 16 - 66
src/injected/web/gm-api.js

@@ -1,4 +1,4 @@
-import { dumpScriptValue, isEmpty } from '#/common/util';
+import { dumpScriptValue, isEmpty, isFunction } from '#/common/util';
 import bridge from './bridge';
 import store from './store';
 import { onTabCreate } from './tabs';
@@ -6,18 +6,6 @@ import { onRequestCreate } from './requests';
 import { onNotificationCreate } from './notifications';
 import { decodeValue, dumpValue, loadValues, changeHooks } from './gm-values';
 import { jsonDump } from './util-web';
-import {
-  NS_HTML, createNullObj, getUniqIdSafe, log,
-  pickIntoThis, promiseResolve, vmOwnFunc,
-} from '../util';
-
-const {
-  TextDecoder,
-  URL: { createObjectURL, revokeObjectURL },
-} = global;
-const { decode: tdDecode } = TextDecoder[PROTO];
-const { indexOf: stringIndexOf } = '';
-let downloadChain = promiseResolve();
 
 export function makeGmApi() {
   return {
@@ -128,13 +116,13 @@ export function makeGmApi() {
         throw new ErrorSafe('Required parameter "name" is missing or not a string.');
       }
       assign(opts, {
-        context: { name, onload },
         method: 'GET',
         responseType: 'blob',
         overrideMimeType: 'application/octet-stream',
-        onload: downloadBlob,
+        // Must be present and a function to trigger downloadBlob in content bridge
+        onload: isFunction(onload) ? onload : (() => {}),
       });
-      return onRequestCreate(opts, this);
+      return onRequestCreate(opts, this, name);
     },
     GM_xmlhttpRequest(opts) {
       return onRequestCreate(opts, this);
@@ -194,12 +182,10 @@ export function makeGmApi() {
 function webAddElement(parent, tag, attrs, context) {
   let el;
   let errorInfo;
-  const cbId = getUniqIdSafe();
-  bridge.callbacks[cbId] = function _(res) {
+  bridge.syncCall('AddElement', { tag, attrs }, context, parent, function _(res) {
     el = this;
     errorInfo = res;
-  };
-  bridge.post('AddElement', { tag, attrs, cbId }, context, parent);
+  }, 'cbId');
   // DOM error in content script can't be caught by a page-mode userscript so we rethrow it here
   if (errorInfo) {
     const err = new ErrorSafe(errorInfo[0]);
@@ -222,55 +208,19 @@ function webAddElement(parent, tag, attrs, context) {
 }
 
 function getResource(context, name, isBlob) {
-  const key = context.resources[name];
+  const { id, resCache, resources } = context;
+  const key = resources[name];
   if (key) {
-    let res = isBlob && context.urls[key];
+    let res = resCache[key];
     if (!res) {
-      const raw = bridge.cache[context.pathMap[key] || key];
-      if (raw) {
-        // 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 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 BlobSafe([bytes], { type }));
-            context.urls[key] = res;
-          } else {
-            res = new TextDecoder()::tdDecode(bytes);
-          }
-        } else { // pure ASCII
-          res = bin;
-        }
-      } else if (isBlob) {
-        res = key;
+      bridge.syncCall('GetResource', { id, isBlob, key }, context, null, response => {
+        res = response;
+      });
+      if (res !== true && isBlob) {
+        res = createObjectURL(res);
       }
+      resCache[key] = res;
     }
-    return res;
+    return res === true ? key : res;
   }
 }
-
-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::fire(new MouseEventSafe('click'));
-    revokeBlobAfterTimeout(url);
-    try { if (onload) onload(res); } catch (e) { log('error', ['GM_download', 'callback'], e); }
-    await bridge.send('SetTimeout', 100);
-  });
-}
-
-async function revokeBlobAfterTimeout(url) {
-  await bridge.send('SetTimeout', 3000);
-  revokeObjectURL(url);
-}

+ 0 - 1
src/injected/web/gm-global-wrapper.js

@@ -2,7 +2,6 @@ import { isFunction } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
 import bridge from './bridge';
 import { FastLookup } from './util-web';
-import { createNullObj, setOwnProp, vmOwnFunc } from '../util';
 
 /** 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 */

+ 0 - 1
src/injected/web/gm-values.js

@@ -1,7 +1,6 @@
 import { forEachEntry } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
-import { createNullObj, log } from '../util';
 
 // Nested objects: scriptId -> keyName -> listenerId -> GMValueChangeListener
 export const changeHooks = createNullObj();

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

@@ -6,10 +6,7 @@ import './gm-values';
 import './notifications';
 import './requests';
 import './tabs';
-import {
-  bindEvents, createNullObj, getUniqIdSafe,
-  isSameOriginWindow, log, setOwnProp, vmOwnFunc,
-} from '../util';
+import { bindEvents } from '../util';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
@@ -78,7 +75,6 @@ bridge.addHandlers({
   },
   ScriptData({ info, items, runAt }) {
     if (info) {
-      info.cache = assign(createNullObj(), info.cache, bridge.cache);
       assign(bridge, info);
     }
     if (items) {

+ 0 - 1
src/injected/web/notifications.js

@@ -1,5 +1,4 @@
 import bridge from './bridge';
-import { createNullObj } from '../util';
 
 let lastId = 0;
 const notifications = createNullObj();

+ 29 - 89
src/injected/web/requests.js

@@ -1,13 +1,7 @@
 import { isFunction } from '#/common';
-import { NS_HTML, createNullObj, getOwnProp, getUniqIdSafe, log, pickIntoThis } from '../util';
 import bridge from './bridge';
 
 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({
   HttpRequested(msg) {
@@ -16,7 +10,7 @@ bridge.addHandlers({
   },
 });
 
-export function onRequestCreate(opts, context) {
+export function onRequestCreate(opts, context, fileName) {
   if (!opts.url) throw new ErrorSafe('Required parameter "url" is missing.');
   const scriptId = context.id;
   const id = getUniqIdSafe(`VMxhr${scriptId}`);
@@ -26,7 +20,7 @@ export function onRequestCreate(opts, context) {
     scriptId,
     opts,
   };
-  start(req, context);
+  start(req, context, fileName);
   return {
     abort() {
       bridge.post('AbortRequest', id, context);
@@ -35,33 +29,17 @@ export function onRequestCreate(opts, context) {
 }
 
 function parseData(req, msg) {
-  let res;
-  const { raw, opts: { responseType } } = req;
-  if (responseType === 'text') {
-    res = raw;
-  } else if (responseType === 'json') {
-    res = jsonParse(raw);
-  } else if (responseType === 'document') {
-    // Cutting everything after , or ; and trimming whitespace
-    const type = msg.contentType::replace(/[,;].*|\s+/g, '') || 'text/html';
-    res = new DOMParser()::parseFromString(raw, type);
-  } else if (msg.chunked) {
-    // arraybuffer/blob in incognito tabs is transferred as ArrayBuffer encoded in string chunks
-    // 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 = window::atobSafe(chunk)).length;
-      for (let j = 0; j < len; j += 1, dstIndex += 1) {
-        arr[dstIndex] = chunk::charCodeAt(j);
-      }
-    });
-    res = responseType === 'blob'
-      ? new BlobSafe([arr], { type: msg.contentType })
-      : arr.buffer;
-  } else {
-    // text, blob, arraybuffer
-    res = raw;
+  let res = req.raw;
+  switch (req.opts.responseType) {
+  case 'json':
+    res = jsonParse(res);
+    break;
+  case 'document':
+    res = new DOMParserSafe()::parseFromString(res,
+      // Cutting everything after , or ; and trimming whitespace
+      msg.contentType::replace(/[,;].*|\s+/g, '') || 'text/html');
+    break;
+  default:
   }
   // `response` is sent only when changed so we need to remember it for response-less events
   req.response = res;
@@ -71,16 +49,9 @@ function parseData(req, msg) {
 }
 
 // request object functions
-async function callback(req, msg) {
-  if (msg.chunk) {
-    receiveChunk(req, msg.chunk);
-    return;
-  }
-  const { chunksPromise, opts } = req;
+function callback(req, msg) {
+  const { opts } = req;
   const cb = opts[`on${msg.type}`];
-  if (chunksPromise) {
-    await chunksPromise;
-  }
   if (cb) {
     const { data } = msg;
     const {
@@ -89,15 +60,9 @@ async function callback(req, msg) {
       responseText: text,
     } = data;
     if (response && !('raw' in req)) {
-      req.raw = msg.chunked
-        ? receiveAllChunks(req, response, msg)
-        : response;
-    }
-    if (req.raw?.then) {
-      req.raw = await req.raw;
+      req.raw = response;
     }
     defineProperty(data, 'response', {
-      configurable: true,
       get() {
         const value = 'raw' in req ? parseData(req, msg) : req.response;
         defineProperty(this, 'response', { value });
@@ -115,31 +80,7 @@ async function callback(req, msg) {
   if (msg.type === 'loadend') delete idMap[req.id];
 }
 
-function receiveAllChunks(req, response, { dataSize, numChunks }) {
-  let res = [response];
-  req.dataSize = dataSize;
-  if (numChunks > 1) {
-    req.chunks = res;
-    req.chunksPromise = new PromiseSafe(resolve => {
-      req.resolve = resolve;
-    });
-    res = req.chunksPromise;
-  }
-  return res;
-}
-
-function receiveChunk(req, { data, i, last }) {
-  const { chunks } = req;
-  chunks[i] = data;
-  if (last) {
-    req.resolve(chunks);
-    delete req.chunksPromise;
-    delete req.chunks;
-    delete req.resolve;
-  }
-}
-
-async function start(req, context) {
+async function start(req, context, fileName) {
   const { id, opts, scriptId } = req;
   // withCredentials is for GM4 compatibility and used only if `anonymous` is not set,
   // it's true by default per the standard/historical behavior of gmxhr
@@ -150,6 +91,7 @@ async function start(req, context) {
     id,
     scriptId,
     anonymous,
+    fileName,
     data: data == null && []
       // `binary` is for TM/GM-compatibility + non-objects = must use a string `data`
       || (opts.binary || typeof data !== 'object') && [`${data}`]
@@ -168,7 +110,6 @@ async function start(req, context) {
       'timeout',
     ]::filter(key => isFunction(getOwnProp(opts, `on${key}`))),
     responseType: getResponseType(opts),
-    url: getFullUrl(opts.url),
     wantsBlob: opts.responseType === 'blob',
   }::pickIntoThis(opts, [
     'headers',
@@ -176,16 +117,11 @@ async function start(req, context) {
     'overrideMimeType',
     'password',
     'timeout',
+    'url',
     'user',
   ]), context);
 }
 
-function getFullUrl(url) {
-  const a = document::createElementNS(NS_HTML, 'a');
-  a::setAttribute('href', url);
-  return a::getHref();
-}
-
 function getResponseType({ responseType = '' }) {
   switch (responseType) {
   case 'arraybuffer':
@@ -203,15 +139,19 @@ function getResponseType({ responseType = '' }) {
   return '';
 }
 
-/** Polyfill for Chrome's inability to send complex types over extension messaging */
+/**
+ * Polyfill for Chrome's inability to send complex types over extension messaging.
+ * We're encoding the body here, not in content, because we want to support FormData
+ * and ReadableStream, which Chrome can't transfer to isolated world via CustomEvent.
+ */
 async function encodeBody(body) {
   const wasBlob = body::objectToString() === '[object Blob]';
-  const blob = wasBlob ? body : await new Response(body)::resBlob();
-  const reader = new FileReader();
-  return new PromiseSafe((resolve) => {
+  const blob = wasBlob ? body : await new ResponseSafe(body)::safeResponseBlob();
+  const reader = new FileReaderSafe();
+  return new PromiseSafe(resolve => {
     reader::on('load', () => resolve([
-      reader.result,
-      blob.type,
+      reader::getReaderResult(),
+      blob::getBlobType(),
       wasBlob,
     ]));
     reader::readAsDataURL(blob);

+ 28 - 23
src/injected/web/safe-globals-web.js

@@ -4,21 +4,21 @@
 /**
  * `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,
+  DOMParserSafe,
   ErrorSafe,
+  FileReaderSafe,
   KeyboardEventSafe,
   MouseEventSafe,
   Object,
   PromiseSafe,
   ProxySafe,
-  Uint8ArraySafe,
-  atobSafe,
+  ResponseSafe,
   fire,
   off,
   on,
@@ -46,28 +46,30 @@ export let
   filter,
   forEach,
   indexOf,
-  map,
   // Element.prototype
   remove,
-  setAttribute,
   // String.prototype
   charCodeAt,
   slice,
   replace,
-  // document
-  createElementNS,
-  // various methods
+  // safeCall
   safeCall,
+  // various methods
+  createObjectURL,
   funcToString,
   jsonParse,
+  logging,
   mathRandom,
-  regexpTest,
+  parseFromString, // DOMParser
+  readAsDataURL, // FileReader
+  safeResponseBlob, // Response - safe = "safe global" to disambiguate the name
   then,
-  logging,
   // various getters
-  getDetail,
-  getRelatedTarget,
-  getCurrentScript;
+  getBlobType, // Blob
+  getCurrentScript, // Document
+  getDetail, // CustomEvent
+  getReaderResult, // FileReader
+  getRelatedTarget; // MouseEvent
 
 /**
  * VAULT consists of the parent's safe globals to protect our communications/globals
@@ -91,14 +93,15 @@ export const VAULT = (() => {
     // window
     BlobSafe = res[i += 1] || window.Blob,
     CustomEventSafe = res[i += 1] || window.CustomEvent,
+    DOMParserSafe = res[i += 1] || window.DOMParser,
     ErrorSafe = res[i += 1] || window.Error,
+    FileReaderSafe = res[i += 1] || window.FileReader,
     KeyboardEventSafe = res[i += 1] || window.KeyboardEvent,
     MouseEventSafe = res[i += 1] || window.MouseEvent,
     Object = res[i += 1] || window.Object,
     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,
+    ResponseSafe = res[i += 1] || window.Response,
     fire = res[i += 1] || window.dispatchEvent,
     off = res[i += 1] || window.removeEventListener,
     on = res[i += 1] || window.addEventListener,
@@ -124,28 +127,30 @@ export const VAULT = (() => {
     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
     safeCall = res[i += 1] || Object.call.bind(Object.call),
+    // various methods
+    createObjectURL = res[i += 1] || URL.createObjectURL,
     funcToString = res[i += 1] || safeCall.toString,
     jsonParse = res[i += 1] || JSON.parse,
+    logging = res[i += 1] || assign({ __proto__: null }, console),
     mathRandom = res[i += 1] || Math.random,
-    regexpTest = res[i += 1] || RegExp[PROTO].test,
+    parseFromString = res[i += 1] || DOMParserSafe[PROTO].parseFromString,
+    readAsDataURL = res[i += 1] || FileReaderSafe[PROTO].readAsDataURL,
+    safeResponseBlob = res[i += 1] || ResponseSafe[PROTO].blob,
     then = res[i += 1] || PromiseSafe[PROTO].then,
-    logging = res[i += 1] || assign({ __proto__: null }, console),
     // various getters
+    getBlobType = res[i += 1] || describeProperty(BlobSafe[PROTO], 'type').get,
+    getCurrentScript = res[i += 1] || describeProperty(Document[PROTO], 'currentScript').get,
     getDetail = res[i += 1] || describeProperty(CustomEventSafe[PROTO], 'detail').get,
+    getReaderResult = res[i += 1] || describeProperty(FileReaderSafe[PROTO], 'result').get,
     getRelatedTarget = res[i += 1] || describeProperty(MouseEventSafe[PROTO], 'relatedTarget').get,
-    getCurrentScript = res[i += 1] || describeProperty(Document[PROTO], 'currentScript').get,
   ];
   return res;
 })();

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

@@ -1,8 +1,5 @@
-import { createNullObj } from '../util';
-
 export default {
   __proto__: null,
   commands: createNullObj(),
   values: createNullObj(),
-  state: 0,
 };

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

@@ -1,5 +1,4 @@
 import bridge from './bridge';
-import { createNullObj } from '../util';
 
 let lastId = 0;
 const tabs = createNullObj();

+ 6 - 3
src/injected/web/util-web.js

@@ -1,6 +1,5 @@
-import { createNullObj } from '../util';
-import bridge from '#/injected/web/bridge';
 import { INJECT_CONTENT } from '#/common/consts';
+import bridge from './bridge';
 
 // Firefox defines `isFinite` on `global` not on `window`
 const { isFinite } = global; // eslint-disable-line no-restricted-properties
@@ -87,7 +86,11 @@ export const FastLookup = (hubs = createNullObj()) => {
       delete getHub(val)?.[val];
     },
     has: val => getHub(val)?.[val],
-    toArray: () => concat::apply([], objectValues(hubs)::map(objectKeys)),
+    toArray: () => {
+      const values = objectValues(hubs);
+      values::forEach((val, i) => { values[i] = objectKeys(val); });
+      return concat::apply([], values);
+    },
   };
   function getHub(val, autoCreate) {
     const group = val.length ? val[0] : ''; // length is unforgeable, index getters aren't

+ 6 - 27
test/injected/gm-resource.test.js

@@ -1,42 +1,21 @@
 import test from 'tape';
 import { buffer2string } from '#/common';
-import { makeGmApiWrapper } from '#/injected/web/gm-api-wrapper';
-import bridge from '#/injected/web/bridge';
+import { decodeResource } from '#/injected/content/util-content';
 
 const stringAsBase64 = str => btoa(buffer2string(new TextEncoder().encode(str).buffer));
 
-const blobAsText = async blobUrl => new Promise(resolve => {
+const blobAsText = async blob => new Promise(resolve => {
   const fr = new FileReader();
   fr.onload = () => resolve(new TextDecoder().decode(fr.result));
-  fr.readAsArrayBuffer(URL._cache[blobUrl]);
+  fr.readAsArrayBuffer(blob);
 });
 
 // WARNING: don't include D800-DFFF range which is for surrogate pairs
 const RESOURCE_TEXT = 'abcd\u1234\u2345\u3456\u4567\u5678\u6789\u789A\u89AB\u9ABC\uABCD';
-/** @type VMScript */
-const script = {
-  config: {},
-  custom: {},
-  props: {
-    id: 1,
-  },
-  meta: {
-    grant: [
-      'GM_getResourceText',
-      'GM_getResourceURL',
-    ],
-    resources: {
-      foo: 'https://dummy.url/foo.txt',
-    },
-  },
-};
-const wrapper = makeGmApiWrapper(script);
-bridge.cache = {
-  [script.meta.resources.foo]: `text/plain,${stringAsBase64(RESOURCE_TEXT)}`,
-};
+const DATA = `text/plain,${stringAsBase64(RESOURCE_TEXT)}`;
 
 test('@resource decoding', async (t) => {
-  t.equal(wrapper.GM_getResourceText('foo'), RESOURCE_TEXT, 'GM_getResourceText');
-  t.equal(await blobAsText(wrapper.GM_getResourceURL('foo')), RESOURCE_TEXT, 'GM_getResourceURL');
+  t.equal(decodeResource(DATA), RESOURCE_TEXT, 'GM_getResourceText');
+  t.equal(await blobAsText(decodeResource(DATA, true)), RESOURCE_TEXT, 'GM_getResourceURL');
   t.end();
 });

+ 0 - 10
test/mock/polyfill.js

@@ -28,16 +28,6 @@ for (const k of Object.keys(domProps)) {
 delete domProps.performance;
 Object.defineProperties(global, domProps);
 global.Response = { prototype: {} };
-
-global.URL = {
-  _cache: {},
-  createObjectURL(blob) {
-    const blobUrl = `blob:${Math.random()}`;
-    URL._cache[blobUrl] = blob;
-    return blobUrl;
-  },
-};
-
 global.__VAULT_ID__ = false;
 Object.assign(global, require('#/common/safe-globals'));
 Object.assign(global, require('#/injected/safe-globals-injected'));