Преглед на файлове

fix #2404: keep sandbox if grantless to avoid bugs + match TM

* disable sandbox only via an explicit `@grant none`
* don't expose unsafeWindow unless explicitly requested via `@grant`
* only expose cloneInto, createObjectIn, exportFunction in the sandboxed mode
tophf преди 6 дни
родител
ревизия
da721f0309

+ 8 - 0
src/_locales/en/messages.yml

@@ -323,6 +323,14 @@ helpForLocalFile:
 hintForBatchAction:
   description: Hint shown next to the buttons for batch actions.
   message: for $1 matching scripts
+hintGrantless:
+  description: >-
+    1) This is a tooltip so preserve the line breaks to ensure it's not too
+    wide. 2) Don't translate back-quoted terms like `window` or `@grant none`.
+  message: |-
+    A sandboxed script cannot change these global properties: $1.
+    To fix the script either add `@grant none` to disable the sandbox,
+    or add `@grant unsafeWindow` and use `unsafeWindow` instead of `window`.
 hintInputURL:
   description: Hint for a prompt box to input URL of a user script.
   message: 'Input URL:'

+ 2 - 3
src/background/utils/preinject.js

@@ -68,7 +68,7 @@ const cache = initCache({
   },
 });
 // KEY_XXX for hooked options
-const GRANT_NONE_VARS = '{GM,GM_info,unsafeWindow,cloneInto,createObjectIn,exportFunction}';
+const GRANT_NONE_VARS = '{GM,GM_info}';
 const META_KEYS_TO_ENSURE = [
   'description',
   'name',
@@ -482,8 +482,7 @@ function prepareScript(script, env) {
   const wrapTryCatch = wrap && IS_FIREFOX; // FF doesn't show errors in content script's console
   const { grant, [TL_AWAIT]: topLevelAwait } = meta;
   const startIIFE = topLevelAwait ? 'await(async' : '(';
-  const numGrants = grant.length;
-  const grantNone = !numGrants || numGrants === 1 && grant[0] === 'none';
+  const grantNone = grant.includes('none');
   const shouldUpdate = !!script.config.shouldUpdate;
   // Storing slices separately to reuse JS-internalized strings for code in our storage cache
   const injectedCode = [];

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

@@ -34,6 +34,7 @@ const bridge = {
   __proto__: null,
   [IDS]: createNullObj(),
   cache: createNullObj(),
+  grantless: 0,
   pathMaps: createNullObj(),
   // realm is provided when called directly via invokeHost
   async onHandle({ cmd, data, node }, realm) {

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

@@ -8,11 +8,13 @@ const { toLowerCase } = '';
 const { [IDS]: ids } = bridge;
 let setPopupThrottle;
 let isPopupShown;
+let grantless;
 
 addBackgroundHandlers({
   async PopupShown(state) {
     await bridge[REIFY];
     isPopupShown = state;
+    if (bridge.grantless) bridge.post('GetGrantless');
     sendSetPopup();
   },
 }, true);
@@ -43,6 +45,10 @@ addHandlers({
     return raw ? decodeResource(raw, isBlob) : true;
   },
 
+  SetGrantless(data) {
+    grantless = data;
+  },
+
   RegisterMenu({ id, key, val }) {
     (menus[id] || (menus[id] = createNullObj()))[key] = val;
     sendSetPopup(true);
@@ -66,6 +72,7 @@ export async function sendSetPopup(isDelayed) {
     await sendCmd('SetPopup', {
       [IDS]: ids,
       [INJECT_INTO]: bridge[INJECT_INTO],
+      grantless,
       menus,
     });
   }

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

@@ -7,8 +7,8 @@ const bridgeIds = bridge[IDS];
 const kWrappedJSObject = 'wrappedJSObject';
 let tardyQueue;
 let bridgeInfo;
-let contLists;
-let pageLists;
+/** @type {{[runAt: VMScriptRunAt]: VMInjection.Script[]}} */
+let contLists, pageLists;
 /** @type {?boolean} */
 let pageInjectable;
 let frameEventWnd;
@@ -331,6 +331,7 @@ function inject(item, iframeCb) {
   div::remove();
 }
 
+/** @param {VMScriptRunAt} runAt */
 function injectAll(runAt) {
   if (contLists && !invokeContent) {
     setupContentInvoker();
@@ -343,7 +344,10 @@ function injectAll(runAt) {
     if (items) {
       bridge.post('ScriptData', { items, info: bridgeInfo[realm] }, realm);
       bridgeInfo[realm] = false; // must be a sendable value to have own prop in the receiver
-      for (const { id } of items) tardyQueue[id] = 1;
+      for (const { id, meta: { grant } } of items) {
+        tardyQueue[id] = 1;
+        bridge.grantless += !grant.length;
+      }
       if (!inPage) nextTask()::then(() => tardyQueueCheck(items));
       else if (!IS_FIREFOX) res = injectPageList(runAt);
     }

+ 31 - 34
src/injected/web/gm-api-wrapper.js

@@ -27,54 +27,51 @@ export function makeGmApiWrapper(script) {
   const { meta } = script;
   const { grant } = meta;
   const resources = setPrototypeOf(meta[kResources], null);
-  /** @type {GMContext} */
-  const context = safePickInto({
-    [kResources]: resources,
-    resCache: createNullObj(),
-    async: false,
-  }, script, COPY_SCRIPT_PROPS);
   const gmInfo = script.gmi;
   const gm4 = createNullObj();
   const gm = {
     __proto__: null,
     GM: gm4,
-    unsafeWindow: global,
   };
   let contextAsync;
+  let grantless;
   let wrapper;
-  let numGrants = grant.length;
-  if (numGrants === 1 && grant[0] === 'none') {
-    numGrants = 0;
-  }
-  assign(gm, componentUtils);
   defineGmInfoProps(makeGmInfo, 'get');
-  for (let name of grant) {
-    let fn, fnGm4, gmName, gm4name;
-    if (name::slice(0, 3) === 'GM.' && (gm4name = name::slice(3)) && (fnGm4 = GM4_ALIAS[gm4name])
-    || (fn = GM_API_CTX[gmName = gm4name ? `GM_${gm4name}` : name])
-    || (fn = GM_API_CTX_GM4ASYNC[gmName]) && (!gm4name || (fnGm4 = fn))) {
-      fn = safeBind(fnGm4 || fn,
-        fnGm4
-          ? contextAsync || (contextAsync = assign(createNullObj(), context, { async: true }))
-          : context);
-    } else if (!(fn = GM_API[gmName]) && (
-      fn = name === 'window.close' && sendTabClose
-        || name === 'window.focus' && sendTabFocus
-    )) {
-      name = name::slice(7); // 'window.'.length
-    }
-    if (fn) {
-      if (gm4name) gm4[gm4name] = fn;
-      else gm[name] = fn;
+  // Sandbox is enabled unless explicitly disabled via `none`, #2404
+  if (grant::indexOf('none') < 0) {
+    /** @type {GMContext} */
+    const context = safePickInto({
+      [kResources]: resources,
+      resCache: createNullObj(),
+      async: false,
+    }, script, COPY_SCRIPT_PROPS);
+    assign(gm, componentUtils);
+    for (let name of grant) {
+      let fn, fnGm4, gmName, gm4name;
+      if (name::slice(0, 3) === 'GM.' && (gm4name = name::slice(3)) && (fnGm4 = GM4_ALIAS[gm4name])
+      || (fn = GM_API_CTX[gmName = gm4name ? `GM_${gm4name}` : name])
+      || (fn = GM_API_CTX_GM4ASYNC[gmName]) && (!gm4name || (fnGm4 = fn))) {
+        fn = safeBind(fnGm4 || fn,
+          fnGm4
+            ? contextAsync || (contextAsync = assign(createNullObj(), context, { async: true }))
+            : context);
+      } else if (!(fn = GM_API[gmName]) && (
+        fn = name === 'window.close' && sendTabClose
+          || name === 'window.focus' && sendTabFocus
+      )) {
+        name = name::slice(7); // 'window.'.length
+      }
+      if (fn) {
+        if (gm4name) gm4[gm4name] = fn;
+        else gm[name] = fn;
+      }
     }
-  }
-  if (numGrants) {
-    wrapper = makeGlobalWrapper(gm);
+    wrapper = makeGlobalWrapper(gm, grantless = !grant.length && createNullObj());
     /* Exposing the fast cache of resolved properties,
      * using a name that'll never be added to the web platform */
     gm.c = gm;
   }
-  return { gm, wrapper };
+  return { gm, wrapper, grantless };
 
   function defineGmInfoProps(value, getter) {
     setOwnProp(gm, 'GM_info', value, true, getter);

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

@@ -192,6 +192,7 @@ export const GM_API = {
   },
   // using the native console.log so the output has a clickable link to the caller's source
   GM_log: logging.log,
+  unsafeWindow: global,
 };
 
 function webAddElement(parent, tag, attrs) {

+ 6 - 2
src/injected/web/gm-global-wrapper.js

@@ -106,7 +106,7 @@ builtinGlobals = null; // eslint-disable-line no-global-assign
 /**
  * @desc Wrap helpers to prevent unexpected modifications.
  */
-export function makeGlobalWrapper(local) {
+export function makeGlobalWrapper(local, grantless) {
   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
@@ -119,6 +119,7 @@ export function makeGlobalWrapper(local) {
       if (name in local
       || !(_ = globalDesc[name] || updateGlobalDesc(name))
       || _.configurable) {
+        if (grantless) grantless[name] = 1;
         /* It's up to caller to protect proto */// eslint-disable-next-line no-restricted-syntax
         return defineProperty(local, name, desc);
       }
@@ -132,6 +133,7 @@ export function makeGlobalWrapper(local) {
         }
         globals.delete(name);
       }
+      if (grantless) grantless[name] = 1;
       return !!_;
     },
     get: (_, name) => {
@@ -141,11 +143,13 @@ export function makeGlobalWrapper(local) {
     },
     getOwnPropertyDescriptor: (_, name) => describeProperty(local, name)
       || proxyDescribe(local, name, wrapper, events),
-    has: (_, name) => name in globalDesc || name in local || updateGlobalDesc(name),
+    has: (_, name) => name in globalDesc || name in local || updateGlobalDesc(name)
+      || grantless && (grantless[name] = 0),
     ownKeys: () => makeOwnKeys(local, globals),
     preventExtensions() {},
     set(_, name, value) {
       if (!(name in local)) proxyDescribe(local, name, wrapper, events);
+      if (grantless) grantless[name] = 1;
       local[name] = value;
       return true;
     },

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

@@ -12,6 +12,7 @@ import { safeConcat } from './util';
 // Make sure to call safe::methods() in code that may run after userscripts
 
 const toRun = createNullObj();
+const grantlessUsage = createNullObj();
 
 export default function initialize(invokeHost, console) {
   if (PAGE_MODE_HANDSHAKE) {
@@ -67,6 +68,9 @@ addHandlers({
     delete callbacks[id];
     if (fn) this::fn(data);
   },
+  GetGrantless() {
+    bridge.post('SetGrantless', grantlessUsage);
+  },
   async Plant({ data: dataKey, win: winKey }) {
     setOwnProp(window, winKey, onCodeSet, true, 'set');
     /* Cleaning up for a script that didn't compile at all due to a syntax error.
@@ -123,7 +127,8 @@ addHandlers({
 function onCodeSet(fn) {
   const item = toRun[fn.name];
   const el = document::getCurrentScript();
-  const { gm, wrapper = global } = makeGmApiWrapper(item);
+  const { gm, wrapper = global, grantless } = makeGmApiWrapper(item);
+  if (grantless) grantlessUsage[item.id] = grantless;
   // Deleting now to prevent interception via DOMNodeRemoved on el::remove()
   delete window[item.key.win];
   if (process.env.DEBUG) {

+ 6 - 1
src/popup/index.js

@@ -1,5 +1,5 @@
 import '@/common/browser';
-import { sendCmdDirectly } from '@/common';
+import { i18n, sendCmdDirectly } from '@/common';
 import handlers from '@/common/handlers';
 import { loadCommandIcon, loadScriptIcon } from '@/common/load-script-icon';
 import { mapEntry } from '@/common/object';
@@ -57,10 +57,12 @@ async function setPopup(data, { [kFrameId]: frameId, url }) {
     // frameScripts may be appended multiple times if iframes have unique scripts
     const { frameScripts } = store;
     const scope = isTop ? store[SCRIPTS] : frameScripts;
+    const { grantless } = data;
     const metas = data[SCRIPTS]?.filter(({ props: { id } }) => ids.includes(id))
       || (Object.assign(data, await sendCmdDirectly('GetData', { ids })))[SCRIPTS];
     metas.forEach(script => {
       loadScriptIcon(script, data);
+      let v;
       const { id } = script.props;
       const state = idMap[id];
       const more = state === MORE;
@@ -77,6 +79,9 @@ async function setPopup(data, { [kFrameId]: frameId, url }) {
       script.runs = state === CONTENT || state === PAGE;
       script.pageUrl = url; // each frame has its own URL
       script.failed = badRealm || state === ID_INJECTING || more;
+      if (grantless && (v = grantless[id]) && delete v.window && (v = Object.keys(v).join(', '))) {
+        script.grantless = i18n('hintGrantless', v.length > 50 ? v.slice(0, 50) + '...' : v);
+      }
       script[MORE] = more;
       script.syntax = state === ID_INJECTING;
       if (badRealm && !store.injectionFailure) {

+ 5 - 0
src/popup/views/app.vue

@@ -114,6 +114,11 @@
                  @click.stop="note = note === TARDY_MATCH ? '' : TARDY_MATCH">
                 <Icon name="info"/>
               </a>
+              <a v-if="item.data.grantless"
+                 class="tardy" tabindex="0" :title="item.data.grantless"
+                 @click.stop="note = note === item.data.grantless ? '' : item.data.grantless">
+                @
+              </a>
             </div>
             <div class="upd ellipsis" :title="item.upd" :data-error="item.updError"/>
           </div>