Selaa lähdekoodia

Merge remote-tracking branch 'LOCAL-main/master' into vue3

# Conflicts:
#	src/injected/content/util.js
#	test/injected/gm-resource.test.js
#	yarn.lock
tophf 3 vuotta sitten
vanhempi
sitoutus
640ab69f59

+ 1 - 1
.eslintrc.js

@@ -2,7 +2,7 @@ const { readGlobalsFile } = require('./scripts/webpack-util');
 
 const FILES_INJECTED = [`src/injected/**/*.js`];
 const FILES_CONTENT = [
-  'src/injected/*.js',
+  'src/injected/index.js',
   'src/injected/content/**/*.js',
 ];
 const FILES_WEB = [`src/injected/web/**/*.js`];

+ 2 - 2
package.json

@@ -63,7 +63,7 @@
   "license": "MIT",
   "dependencies": {
     "@violentmonkey/shortcut": "^1.2.6",
-    "@zip.js/zip.js": "^2.4.26",
+    "@zip.js/zip.js": "2.4.4",
     "codemirror": "^5.65.6",
     "codemirror-js-mixed": "^0.9.2",
     "tldjs": "^2.3.1",
@@ -78,5 +78,5 @@
       "./test/mock/index.js"
     ]
   },
-  "beta": 1
+  "beta": 2
 }

+ 1 - 1
src/background/index.js

@@ -72,7 +72,7 @@ Object.assign(commands, {
   /**
    * Timers in content scripts are shared with the web page so it can clear them.
    * await sendCmd('SetTimeout', 100) in injected/content
-   * await bridge.send('SetTimeout', 100) in injected/web
+   * bridge.call('SetTimeout', 100, cb) in injected/web
    */
   SetTimeout(ms) {
     return ms > 0 && makePause(ms);

+ 4 - 3
src/background/utils/requests.js

@@ -156,8 +156,9 @@ async function httpRequest(opts, src, cb) {
   const { xhr } = req;
   const vmHeaders = [];
   // Firefox can send Blob/ArrayBuffer directly
-  const chunked = !IS_FIREFOX && incognito;
-  const blobbed = xhrType && !IS_FIREFOX && !incognito;
+  const willStringifyBinaries = xhrType && !IS_FIREFOX;
+  const chunked = willStringifyBinaries && incognito;
+  const blobbed = willStringifyBinaries && !incognito;
   const [body, contentType] = decodeBody(opts.data);
   // Chrome can't fetch Blob URL in incognito so we use chunks
   req.blobbed = blobbed;
@@ -178,7 +179,7 @@ async function httpRequest(opts, src, cb) {
       shouldSendCookies = !/^cookie$/i.test(name);
     }
   });
-  xhr.responseType = (chunked || blobbed) && 'blob' || xhrType || 'text';
+  xhr.responseType = willStringifyBinaries && 'blob' || xhrType || 'text';
   xhr.timeout = Math.max(0, Math.min(0x7FFF_FFFF, opts.timeout)) || 0;
   if (overrideMimeType) xhr.overrideMimeType(overrideMimeType);
   if (shouldSendCookies) {

+ 1 - 1
src/injected/content/bridge.js

@@ -1,5 +1,5 @@
 import { INJECT_PAGE, browser } from '../util';
-import { sendCmd } from './util-content';
+import { sendCmd } from './util';
 
 const allowed = createNullObj();
 const dataKeyNameMap = createNullObj();

+ 1 - 1
src/injected/content/cmd-run.js

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { sendCmd } from './util-content';
+import { sendCmd } from './util';
 import { INJECT_CONTENT } from '../util';
 
 const { runningIds } = bridge;

+ 3 - 3
src/injected/content/gm-api-content.js

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { decodeResource, elemByTag, makeElem, sendCmd } from './util-content';
+import { decodeResource, elemByTag, makeElem, nextTask, sendCmd } from './util';
 
 const menus = createNullObj();
 let setPopupThrottle;
@@ -62,11 +62,11 @@ export async function sendSetPopup(isDelayed) {
     if (isDelayed) {
       if (setPopupThrottle) return;
       // Preventing flicker in popup when scripts re-register menus
-      setPopupThrottle = sendCmd('SetTimeout', 0);
+      setPopupThrottle = nextTask;
       await setPopupThrottle;
       setPopupThrottle = null;
     }
-    sendCmd('SetPopup', { menus, __proto__: null }::pickIntoThis(bridge, [
+    sendCmd('SetPopup', createNullObj({ menus }, bridge, [
       'ids',
       'injectInto',
       'runningIds',

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

@@ -5,7 +5,7 @@ import { injectPageSandbox, injectScripts } from './inject';
 import './notifications';
 import './requests';
 import './tabs';
-import { sendCmd } from './util-content';
+import { nextTask, sendCmd } from './util';
 import { isEmpty, INJECT_CONTENT } from '../util';
 import { Run } from './cmd-run';
 
@@ -33,7 +33,7 @@ async function init() {
       : await dataPromise
   );
   const { allowCmd } = bridge;
-  bridge::pickIntoThis(data, [
+  createNullObj(bridge, data, [
     'ids',
     'injectInto',
   ]);
@@ -44,7 +44,7 @@ async function init() {
   }
   if (data.scripts) {
     bridge.onScripts.forEach(fn => fn(data));
-    allowCmd('SetTimeout', contentId);
+    allowCmd('NextTask', contentId);
     if (IS_FIREFOX) allowCmd('InjectList', contentId);
     await injectScripts(contentId, webId, data, isXml);
   }
@@ -71,7 +71,7 @@ bridge.addBackgroundHandlers({
 
 bridge.addHandlers({
   Run,
-  SetTimeout: true,
+  NextTask: nextTask,
   TabFocus: true,
   UpdateValue: true,
 });

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

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { elemByTag, makeElem, onElement, sendCmd } from './util-content';
+import { elemByTag, makeElem, nextTask, onElement, sendCmd } from './util';
 import {
   bindEvents, fireBridgeEvent,
   INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
@@ -26,13 +26,7 @@ let injectedRoot;
 let VMInitInjection = window[INIT_FUNC_NAME];
 /** Avoid running repeatedly due to new `documentElement` or with declarativeContent in Chrome.
  * The prop's mode is overridden to be unforgeable by a userscript in content mode. */
-defineProperty(window, INIT_FUNC_NAME, {
-  __proto__: null,
-  value: 1,
-  configurable: false,
-  enumerable: false,
-  writable: false,
-});
+setOwnProp(window, INIT_FUNC_NAME, 1, false);
 if (IS_FIREFOX) {
   window::on(VAULT_WRITER, evt => {
     evt::stopImmediatePropagation();
@@ -318,7 +312,7 @@ async function injectList(runAt) {
   // Not using for-of because we don't know if @@iterator is safe.
   for (let i = 0, item; (item = list[i]); i += 1) {
     if (item.code) {
-      if (runAt === 'idle') await sendCmd('SetTimeout', 0);
+      if (runAt === 'idle') await nextTask();
       if (runAt === 'end') await 0;
       inject(item);
       item.code = '';

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

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

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

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { getFullUrl, makeElem, sendCmd } from './util-content';
+import { getFullUrl, makeElem, sendCmd } from './util';
 
 const {
   fetch: safeFetch,
@@ -19,11 +19,10 @@ let downloadChain = promiseResolve();
 // TODO: extract all prop names used across files into consts.js to ensure sameness
 bridge.addHandlers({
   async HttpRequest(msg, realm) {
-    requests[msg.id] = {
-      __proto__: null,
+    requests[msg.id] = createNullObj({
       realm,
       wantsBlob: msg.xhrType === 'blob',
-    }::pickIntoThis(msg, [
+    }, msg, [
       'eventsToNotify',
       'fileName',
     ]);
@@ -113,7 +112,7 @@ async function revokeBlobAfterTimeout(url) {
 
 /** ArrayBuffer/Blob in Chrome incognito is transferred in string chunks */
 function receiveAllChunks(req, msg) {
-  req::pickIntoThis(msg, ['dataSize', 'contentType']);
+  createNullObj(req, msg, ['dataSize', 'contentType']);
   req.arr = new SafeUint8Array(req.dataSize);
   processChunk(req, msg.data.response, 0);
   return !req.gotChunks

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

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

+ 21 - 0
src/injected/content/util-task.js

@@ -0,0 +1,21 @@
+const getData = describeProperty(MessageEvent[PROTO], 'data').get;
+const { port1, port2 } = new MessageChannel();
+const postMessage = port1.postMessage.bind(port1);
+const queue = createNullObj();
+
+let uniqId = 0;
+
+port2.onmessage = evt => {
+  const id = evt::getData();
+  const cb = queue[id];
+  delete queue[id];
+  if (uniqId === id) uniqId -= 1;
+  cb();
+};
+
+export function nextTask() {
+  return new SafePromise(resolve => {
+    queue[uniqId += 1] = resolve;
+    postMessage(uniqId);
+  });
+}

+ 1 - 0
src/injected/content/util-content.js → src/injected/content/util.js

@@ -1,5 +1,6 @@
 // eslint-disable-next-line no-restricted-imports
 export { sendCmd } from '@/common';
+export * from './util-task';
 
 /** When looking for documentElement, use '*' to also support XML pages
  * Note that we avoid spoofed prototype getters by using hasOwnProperty, and not using `length`

+ 42 - 38
src/injected/safe-globals-injected.js

@@ -19,13 +19,9 @@ 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 getObjectTypeTag = val => {
-  // objectToString may call @@toStringTag getter which may throw
-  try {
-    return val && val::objectToString()::slice(8, -1);
-  } catch (e) { /* NOP */ }
-};
+/** These toString are used to avoid leaking data when converting into a string */
+const { toString: numberToString } = 0;
+const { toString: URLToString } = URL[PROTO];
 
 export const isFunction = val => typeof val === 'function';
 export const isObject = val => val !== null && typeof val === 'object';
@@ -47,20 +43,45 @@ export const getOwnProp = (obj, key) => {
 /** 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) => (
+export const setOwnProp = (obj, key, value, mutable = true) => (
   defineProperty(obj, key, {
     __proto__: null,
     value,
-    configurable: true,
-    enumerable: true,
-    writable: true,
+    configurable: mutable,
+    enumerable: mutable,
+    writable: mutable,
   })
 );
 
 export const vmOwnFuncToString = () => '[Violentmonkey property]';
 
-/** Using __proto__ because Object.create(null) may be spoofed */
-export const createNullObj = () => ({ __proto__: null });
+/**
+ * Helps avoid interception via `Object.prototype`.
+ * @param {Object} [dst] - target object to clear the prototype or to pick into
+ * @param {Object} [src] - source object to pick from
+ * @param {string[]} [keys] - all keys will be picked otherwise
+ * @returns {Object} `dst` if it's already without prototype, a new object otherwise
+ */
+export const createNullObj = (dst, src, keys) => {
+  const empty = (!dst || dst.__proto__) && { __proto__: null }; // eslint-disable-line no-proto
+  if (!dst) {
+    dst = empty;
+  } else if (empty) {
+    dst = assign(empty, dst);
+  }
+  if (src) {
+    if (keys) {
+      keys::forEach(key => {
+        if (src::hasOwnProperty(key)) {
+          dst[key] = src[key];
+        }
+      });
+    } else {
+      assign(dst, src);
+    }
+  }
+  return dst;
+};
 
 // WARNING! `obj` must use __proto__:null
 export const ensureNestedProp = (obj, bucketId, key, defaultValue) => {
@@ -76,14 +97,11 @@ export const ensureNestedProp = (obj, bucketId, key, defaultValue) => {
 export const promiseResolve = () => (async () => {})();
 
 export const vmOwnFunc = (func, toString) => (
-  defineProperty(func, 'toString', {
-    __proto__: null,
-    value: toString || vmOwnFuncToString,
-  })
+  setOwnProp(func, 'toString', toString || vmOwnFuncToString, false)
 );
 
-// Avoiding the need to safe-guard a bunch of methods so we use just one
-export const safeGetUniqId = (prefix = 'VM') => `${prefix}${mathRandom()}`;
+// Using just one random() to avoid many methods in vault just for this
+export const safeGetUniqId = (prefix = 'VM') => prefix + mathRandom()::numberToString(36);
 
 /** args is [tags?, ...rest] */
 export const log = (level, ...args) => {
@@ -93,29 +111,15 @@ export const log = (level, ...args) => {
   logging[level]::apply(logging, args);
 };
 
-/**
- * Picks into `this`
- * WARNING! `this` must use __proto__:null or already have own properties on the picked keys.
- * @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;
-}
-
 /**
  * Object.defineProperty seems to be inherently broken: it reads inherited props from desc
  * (even though the purpose of this API is to define own props) and then complains when it finds
  * invalid props like an inherited setter when you only provide `{value}`.
  */
 export const safeDefineProperty = (obj, key, desc) => (
-  defineProperty(obj, key, assign(createNullObj(), desc))
+  defineProperty(obj, key, createNullObj(desc))
+);
+
+export const safePush = (arr, val) => (
+  setOwnProp(arr, arr.length, val)
 );

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

@@ -14,11 +14,19 @@ const bridge = {
     if (fn) node::fn(data);
   },
   send(cmd, data, context, node) {
-    return new SafePromise(resolve => {
-      postWithCallback(cmd, data, context, node, resolve);
-    });
+    let cb;
+    let res;
+    try {
+      res = new UnsafePromise(resolve => {
+        cb = resolve;
+      });
+    } catch (e) {
+      // Unavoidable since vault's Promise can't be used after the iframe is removed
+    }
+    postWithCallback(cmd, data, context, node, cb);
+    return res;
   },
-  syncCall: postWithCallback,
+  call: postWithCallback,
 };
 
 function postWithCallback(cmd, data, context, node, cb, customCallbackId) {

+ 1 - 1
src/injected/web/gm-api-wrapper.js

@@ -36,7 +36,7 @@ export function makeGmApiWrapper(script) {
     grant.length = 0;
   }
   const { id } = script.props;
-  const resources = assign(createNullObj(), meta.resources);
+  const resources = createNullObj(meta.resources);
   /** @namespace VMInjectedScript.Context */
   const context = {
     id,

+ 6 - 9
src/injected/web/gm-api.js

@@ -102,7 +102,7 @@ export function makeGmApi() {
       } else if (arg1) {
         name = arg1.name;
         onload = arg1.onload;
-        opts::pickIntoThis(arg1, [
+        createNullObj(opts, arg1, [
           'url',
           'headers',
           'timeout',
@@ -147,12 +147,9 @@ export function makeGmApi() {
       return webAddElement(null, 'style', { textContent: css, id: safeGetUniqId('VMst') }, this);
     },
     GM_openInTab(url, options) {
-      return onTabCreate(
-        isObject(options)
-          ? assign(createNullObj(), options, { url })
-          : { active: !options, url },
-        this,
-      );
+      options = createNullObj(isObject(options) ? options : { active: !options });
+      options.url = url;
+      return onTabCreate(options, this);
     },
     GM_notification(text, title, image, onclick) {
       const options = isObject(text) ? text : {
@@ -181,7 +178,7 @@ export function makeGmApi() {
 function webAddElement(parent, tag, attrs, context) {
   let el;
   let errorInfo;
-  bridge.syncCall('AddElement', { tag, attrs }, context, parent, function _(res) {
+  bridge.call('AddElement', { tag, attrs }, context, parent, function _(res) {
     el = this;
     errorInfo = res;
   }, 'cbId');
@@ -210,7 +207,7 @@ function getResource(context, name, isBlob) {
   if (key) {
     let res = ensureNestedProp(resCache, bucketKey, key, false);
     if (!res) {
-      bridge.syncCall('GetResource', { id, isBlob, key }, context, null, response => {
+      bridge.call('GetResource', { id, isBlob, key }, context, null, response => {
         res = response;
       });
       if (res !== true && isBlob) {

+ 8 - 4
src/injected/web/gm-global-wrapper.js

@@ -4,7 +4,7 @@ import { FastLookup, safeConcat } from './util-web';
 
 /** 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 */
-const isFrameIndex = key => key >= 0 && key <= 0xFFFF_FFFE && key === `${+key}`;
+const isFrameIndex = key => key >= 0 && key <= 0xFFFF_FFFE && key === (+key)::numberToString();
 const scopeSym = SafeSymbol.unscopables;
 const globalKeysSet = FastLookup();
 const globalKeys = (function makeGlobalKeys() {
@@ -143,7 +143,7 @@ for (const name in unforgeables) { /* proto is null */// eslint-disable-line gua
   );
   let fn;
   if (info) {
-    info = assign(createNullObj(), info);
+    info = createNullObj(info);
     // currently only `document` and `window`
     if ((fn = info.get)) info.get = fn::bind(thisObj);
     // currently only `location`
@@ -165,7 +165,7 @@ builtinGlobals = null; // eslint-disable-line no-global-assign
  */
 export function makeGlobalWrapper(local) {
   const events = createNullObj();
-  const readonlys = assign(createNullObj(), readonlyKeys);
+  const readonlys = createNullObj(readonlyKeys);
   let globals = globalKeysSet; // will be copied only if modified
   /* Browsers may return [object Object] for Object.prototype.toString(window)
      on our `window` proxy so jQuery libs see it as a plain object and throw
@@ -254,7 +254,11 @@ function makeOwnKeys(local, globals) {
   const names = getOwnPropertyNames(local)::filter(notIncludedIn, globals);
   const symbols = getOwnPropertySymbols(local)::filter(notIncludedIn, globals);
   const frameIndexes = [];
-  for (let i = 0, s; getObjectTypeTag(global[s = `${i}`]) === 'Window'; i += 1) {
+  // No way to verify a cross-origin window is a `Window`, so let's just trust the numbers
+  for (let i = 0, s, w, len = getOwnProp(window, 'length');
+    i < len && isObject(w = window[s = i::numberToString()]) && getOwnProp(w, 'window') === w;
+    i += 1
+  ) {
     if (!(s in local)) {
       setOwnProp(frameIndexes, s, s);
     }

+ 41 - 12
src/injected/web/index.js

@@ -9,10 +9,10 @@ import { bindEvents, INJECT_PAGE, INJECT_CONTENT } from '../util';
 
 // Make sure to call safe::methods() in code that may run after userscripts
 
-const sendSetTimeout = () => bridge.send('SetTimeout', 0);
+const queueMacrotask = cb => bridge.call('NextTask', 0, bridge, 0, cb);
 // Waiting for injection of content mode scripts that don't run on document-start
-let resolvers;
-let waiters;
+let runningQueues;
+let waitingQueues;
 
 export default function initialize(
   webId,
@@ -30,8 +30,8 @@ export default function initialize(
   }
   bridge.dataKey = contentId;
   if (invokeHost) {
-    resolvers = createNullObj();
-    waiters = createNullObj();
+    runningQueues = [];
+    waitingQueues = createNullObj();
     bridge.mode = INJECT_CONTENT;
     bridge.post = (cmd, data, context, node) => {
       invokeHost({ cmd, data, node, dataKey: (context || bridge).dataKey }, INJECT_CONTENT);
@@ -43,7 +43,7 @@ export default function initialize(
     global.chrome = undefined;
     global.browser = undefined;
     bridge.addHandlers({
-      RunAt: name => resolvers[name](),
+      RunAt,
     });
   } else {
     bridge.mode = INJECT_PAGE;
@@ -75,8 +75,8 @@ bridge.addHandlers({
       assign(bridge, info);
     }
     if (items) {
-      if (waiters && runAt !== 'start') {
-        waiters[runAt] = new SafePromise(resolve => { resolvers[runAt] = resolve; });
+      if (waitingQueues && runAt !== 'start') {
+        waitingQueues[runAt] = [];
       }
       items::forEach(createScriptData);
       // FF bug workaround to enable processing of sourceURL in injected page scripts
@@ -121,11 +121,40 @@ async function onCodeSet(item, fn) {
     (wrapper || global)::fn(gm, logging.error);
   };
   const el = document::getCurrentScript();
-  const wait = waiters?.[stage];
-  if (el) el::remove();
-  if (wait) {
-    waiters[stage] = (stage === 'idle' ? wait::then(sendSetTimeout) : wait)::then(run);
+  const queue = waitingQueues?.[stage];
+  if (el) {
+    el::remove();
+  }
+  if (queue) {
+    if (stage === 'idle') safePush(queue, 1);
+    safePush(queue, run);
   } else {
     run();
   }
 }
+
+async function RunAt(name) {
+  if (name) {
+    safePush(runningQueues, waitingQueues[name]);
+    if (runningQueues.length > 1) return;
+  }
+  for (let i = 0, queue; i < runningQueues.length; i += 1) {
+    if ((queue = waitingQueues[i])) {
+      for (let j = 0, fn; j < queue.length; j += 1) {
+        fn = queue[j];
+        if (fn) {
+          queue[j] = null;
+          if (fn === 1) {
+            queueMacrotask(RunAt);
+            return;
+          }
+          await 0;
+          fn();
+        }
+      }
+      queue.length = 0;
+      runningQueues[i] = null;
+    }
+  }
+  runningQueues.length = 0;
+}

+ 33 - 12
src/injected/web/requests.js

@@ -10,9 +10,24 @@ bridge.addHandlers({
 });
 
 export function onRequestCreate(opts, context, fileName) {
-  if (!opts.url) throw new SafeError('Required parameter "url" is missing.');
+  opts = createNullObj(opts);
+  let { url } = opts;
+  if (url && !isString(url)) { // USVString in XMLHttpRequest spec calls ToString
+    try {
+      url = url::URLToString();
+    } catch (e) {
+      url = getOwnProp(url, 'href'); // `location`
+    }
+    opts.url = url;
+  }
+  if (!url) {
+    const err = new SafeError('Required parameter "url" is missing.');
+    const { onerror } = opts;
+    if (isFunction(onerror)) onerror(err);
+    else throw err;
+  }
   const scriptId = context.id;
-  const id = safeGetUniqId(`VMxhr${scriptId}`);
+  const id = safeGetUniqId('VMxhr');
   const req = {
     __proto__: null,
     id,
@@ -94,14 +109,12 @@ function callback(req, msg) {
 }
 
 function start(req, context, fileName) {
-  const { id, scriptId } = req;
-  const opts = assign(createNullObj(), req.opts);
+  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
   const { data, withCredentials = true, anonymous = !withCredentials } = opts;
   idMap[id] = req;
-  bridge.post('HttpRequest', {
-    __proto__: null,
+  bridge.post('HttpRequest', createNullObj({
     id,
     scriptId,
     anonymous,
@@ -111,11 +124,8 @@ function start(req, context, fileName) {
       || (opts.binary || !isObject(data)) && [`${data}`]
       // FF56+ can send any cloneable data directly, FF52-55 can't due to https://bugzil.la/1371246
       || IS_FIREFOX && bridge.ua.browserVersion >= 56 && [data]
-      /* Chrome can't directly transfer FormData to isolated world so we explode it,
-       * trusting its iterator is usable because the only reason for a site to break it
-       * is to fight a userscript, which it can do by breaking FormData constructor anyway */
-      // eslint-disable-next-line no-restricted-syntax
-      || (getObjectTypeTag(data) === 'FormData' ? [[...data], 'fd'] : [data, 'bin']),
+      || getFormData(data)
+      || [data, 'bin'],
     eventsToNotify: [
       'abort',
       'error',
@@ -127,7 +137,7 @@ function start(req, context, fileName) {
       'timeout',
     ]::filter(key => isFunction(getOwnProp(opts, `on${key}`))),
     xhrType: getResponseType(opts.responseType),
-  }::pickIntoThis(opts, [
+  }, opts, [
     'headers',
     'method',
     'overrideMimeType',
@@ -138,6 +148,17 @@ function start(req, context, fileName) {
   ]), context);
 }
 
+/** Chrome can't directly transfer FormData to isolated world so we explode it,
+ * trusting its iterator is usable because the only reason for a site to break it
+ * is to fight a userscript, which it can do by breaking FormData constructor anyway */
+function getFormData(data) {
+  try {
+    return [[...data::formDataEntries()], 'fd']; // eslint-disable-line no-restricted-syntax
+  } catch (e) {
+    /**/
+  }
+}
+
 function getResponseType(responseType = '') {
   switch (responseType) {
   case 'arraybuffer':

+ 11 - 7
src/injected/web/safe-globals-web.js

@@ -6,6 +6,13 @@
  * `export` is stripped in the final output and is only used for our NodeJS test scripts.
  */
 
+export const {
+  /* We can't use safe Promise from vault because it stops working when iframe is removed,
+   * so we use the unsafe current global - only for userscript API stuff, not internally.
+   * TODO: try reimplementing Promise in our sandbox wrapper if it can work with user code */
+  Promise: UnsafePromise,
+} = global;
+
 export let
   // window
   SafeCustomEvent,
@@ -15,7 +22,6 @@ export let
   SafeKeyboardEvent,
   SafeMouseEvent,
   Object,
-  SafePromise,
   SafeProxy,
   SafeSymbol,
   fire,
@@ -55,6 +61,7 @@ export let
   // various methods
   arrayIsArray,
   createObjectURL,
+  formDataEntries,
   funcToString,
   jsonParse,
   jsonStringify,
@@ -104,7 +111,6 @@ export const VAULT = (() => {
     SafeKeyboardEvent = res[i += 1] || src.KeyboardEvent,
     SafeMouseEvent = res[i += 1] || src.MouseEvent,
     Object = res[i += 1] || src.Object,
-    SafePromise = res[i += 1] || src.Promise,
     SafeSymbol = res[i += 1] || src.Symbol,
     // In FF content mode global.Proxy !== window.Proxy
     SafeProxy = res[i += 1] || src.Proxy,
@@ -138,20 +144,18 @@ export const VAULT = (() => {
     safeCall = res[i += 1] || (call = SafeObject.call).bind(call),
     // various methods
     createObjectURL = res[i += 1] || src.URL.createObjectURL,
+    formDataEntries = res[i += 1] || src.FormData[PROTO].entries,
     funcToString = res[i += 1] || safeCall.toString,
     arrayIsArray = res[i += 1] || src.Array.isArray,
     /* Exporting JSON methods separately instead of exporting SafeJSON as its props may be broken
      * by the page if it gains access to any Object from the vault e.g. a thrown SafeError. */
     jsonParse = res[i += 1] || src.JSON.parse,
     jsonStringify = res[i += 1] || src.JSON.stringify,
-    logging = res[i += 1] || assign(createNullObj(), src.console),
+    logging = res[i += 1] || createNullObj(src.console),
     mathRandom = res[i += 1] || src.Math.random,
     parseFromString = res[i += 1] || SafeDOMParser[PROTO].parseFromString,
     stopImmediatePropagation = res[i += 1] || src.Event[PROTO].stopImmediatePropagation,
-    then = res[i += 1] || (
-      // Freezing SafePromise in page context to avoid spoofing via eval on an object from vault
-      src !== global ? SafeObject.freeze(SafePromise[PROTO]) : SafePromise[PROTO]
-    ).then,
+    then = res[i += 1] || src.Promise[PROTO].then,
     // various getters
     getCurrentScript = res[i += 1] || describeProperty(src.Document[PROTO], 'currentScript').get,
     getDetail = res[i += 1] || describeProperty(SafeCustomEvent[PROTO], 'detail').get,

+ 1 - 1
src/injected/web/util-web.js

@@ -67,7 +67,7 @@ export const FastLookup = (hubs = createNullObj()) => {
     clone() {
       const clone = createNullObj();
       for (const group in hubs) { /* proto is null */// eslint-disable-line guard-for-in
-        clone[group] = assign(createNullObj(), hubs[group]);
+        clone[group] = createNullObj(hubs[group]);
       }
       return FastLookup(clone);
     },

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

@@ -1,5 +1,5 @@
 import { buffer2string } from '@/common';
-import { decodeResource } from '@/injected/content/util-content';
+import { decodeResource } from '@/injected/content/util';
 
 const stringAsBase64 = str => btoa(buffer2string(new TextEncoder().encode(str).buffer));
 

+ 1 - 0
test/mock/polyfill.js

@@ -33,6 +33,7 @@ for (const k of Object.keys(domProps)) {
   }
 }
 Object.defineProperties(global, domProps);
+delete MessagePort.prototype.onmessage; // to avoid hanging
 global.__VAULT_ID__ = false;
 Object.assign(global, require('@/common/safe-globals'));
 Object.assign(global, require('@/injected/safe-globals-injected'));

+ 4 - 4
yarn.lock

@@ -2336,10 +2336,10 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
-"@zip.js/zip.js@^2.4.26":
-  version "2.6.28"
-  resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.6.28.tgz#f5067d27d53545e2a5ce39518ce40210f0fc8557"
-  integrity sha512-b7WKt2eTMxsecPXz2Qi4P19NCT0f+qMrmHvpnpWitBpnZWQApbVYEb0nusV8gUNxxQ1EA+sCTRdBM/cSn9aMDg==
+"@zip.js/zip.js@2.4.4":
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.4.4.tgz#577237ce8b1844b41b9986dc1ce31cf8b8c577da"
+  integrity sha512-1gtG+uWsU/0GwaH/088QE0NS/9OC9rV1kmurQ8wL1zGtAjqfAaEb+IZGWpd4n6J0whZ139xbTLt858q6cJGiDA==
 
 abab@^2.0.3, abab@^2.0.5, abab@^2.0.6:
   version "2.0.6"