Przeglądaj źródła

fix: SafePromise is unusable

Promise is internally wired to its original environment via [[Realm]],
so it stops working after we remove the iframe.
tophf 3 lat temu
rodzic
commit
83bd2497f5

+ 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);

+ 2 - 2
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,7 +62,7 @@ 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;
     }

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

@@ -5,7 +5,7 @@ import { injectPageSandbox, injectScripts } from './inject';
 import './notifications';
 import './requests';
 import './tabs';
-import { sendCmd } from './util';
+import { nextTask, sendCmd } from './util';
 import { isEmpty, INJECT_CONTENT } from '../util';
 import { Run } from './cmd-run';
 
@@ -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,
 });

+ 2 - 2
src/injected/content/inject.js

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { elemByTag, makeElem, onElement, sendCmd } from './util';
+import { elemByTag, makeElem, nextTask, onElement, sendCmd } from './util';
 import {
   bindEvents, fireBridgeEvent,
   INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
@@ -312,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 = '';

+ 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.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`

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

@@ -116,3 +116,7 @@ export function pickIntoThis(obj, keys) {
 export const safeDefineProperty = (obj, key, desc) => (
   defineProperty(obj, key, assign(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) {

+ 2 - 2
src/injected/web/gm-api.js

@@ -181,7 +181,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 +210,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) {

+ 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;
+}

+ 8 - 6
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,
@@ -104,7 +110,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,
@@ -148,10 +153,7 @@ export const VAULT = (() => {
     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,