Browse Source

fix: sandbox polishing

* improved/fixed the Proxy

* globalKeys is used again to prevent userscripts from seeing page vars directly

* various speedups and fixes
tophf 5 years ago
parent
commit
01a37a7db6

+ 52 - 0
scripts/sandbox-globals.html

@@ -0,0 +1,52 @@
+<p>Run it both in Chrome and FF</p>
+<textarea id="results" style="width:30em; height: 90%" spellcheck="false"></textarea>
+
+<script>
+const el = document.getElementById('results');
+el.value = [
+  function boundMethods() {
+    const keys = [];
+    /* global globalThis */
+    for (const k in globalThis) {
+      if (k >= 'a' && k <= 'z') {
+        const { value } = Object.getOwnPropertyDescriptor(globalThis, k)
+        || Object.getOwnPropertyDescriptor(EventTarget.prototype, k)
+        || Object.getOwnPropertyDescriptor(Object.prototype, k)
+        || {};
+        if (typeof value === 'function') {
+          if (k === 'createImageBitmap' || k === 'fetch') {
+            keys.push(k);
+          } else {
+            try {
+              globalThis[k].call({});
+            } catch (e) {
+              if (/Illegal invocation|implement interface/.test(e)) {
+                keys.push(k);
+              }
+            }
+          }
+        }
+      }
+    }
+    return keys.sort().map(k => `'${k}',`).join('\n');
+  },
+  function unforgeables() {
+    return Object.entries(Object.getOwnPropertyDescriptors(window))
+    .filter(([, v]) => !v.configurable)
+    .map(([k]) => `'${k}',`)
+    .sort()
+    .join('\n');
+  },
+  function readonlys() {
+    return Object.entries(Object.getOwnPropertyDescriptors(window))
+    .filter(([k, v]) => k >= 'a' && k <= 'z'
+      && k !== 'webkitStorageInfo' // deprecated
+      && v.get && !v.set && v.configurable)
+    .map(([k]) => `'${k}',`)
+    .sort()
+    .join('\n');
+  },
+].map(fn => `// ${fn.name}\n${fn()}\n`).join('\n');
+el.focus();
+el.select();
+</script>

+ 2 - 2
scripts/webpack.conf.js

@@ -67,7 +67,7 @@ const modify = (extra, init) => modifyWebpackConfig(
 
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts
-const skipReinjectionHeader = `if (window[Symbol.for('${INIT_FUNC_NAME}')] !== 1)`;
+const skipReinjectionHeader = `if (window['${INIT_FUNC_NAME}'] !== 1)`;
 const skipReinjectionConfig = (config, test) => config.plugins.push(
   new WrapperWebpackPlugin({
     header: skipReinjectionHeader,
@@ -94,7 +94,7 @@ module.exports = Promise.all([
     config.plugins.push(
       new WrapperWebpackPlugin({
         header: `${skipReinjectionHeader}
-          window[Symbol.for('${INIT_FUNC_NAME}')] = function () {
+          window['${INIT_FUNC_NAME}'] = function () {
             var module = { exports: {} };
           `,
         footer: `

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

@@ -36,7 +36,7 @@ const { split } = String.prototype;
   if (data.scripts) injectScripts(contentId, webId, data, isXml);
   isPopupShown = data.isPopupShown;
   sendSetPopup();
-})();
+})().catch(!global.chrome.app && console.error); // Firefox can't show exceptions in content scripts
 
 bridge.addBackgroundHandlers({
   Command(data) {

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

@@ -9,22 +9,22 @@ import { sendCmd } from '#/common';
 import { defineProperty, describeProperty, forEachEntry, objectPick } from '#/common/object';
 
 import {
-  forEach, push, setTimeout,
-  append, createElementNS, remove, DocProto, NS_HTML,
+  forEach, push,
+  append, createElementNS, remove, NS_HTML,
 } from '../utils/helpers';
 import bridge from './bridge';
 
 // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1408996
-const VMInitInjection = window[Symbol.for(process.env.INIT_FUNC_NAME)];
+const VMInitInjection = window[process.env.INIT_FUNC_NAME];
 // To avoid running repeatedly due to new `document.documentElement`
-// (the symbol is undeletable so a userscript can't fool us on reinjection)
-defineProperty(window, Symbol.for(process.env.INIT_FUNC_NAME), { value: 1 });
+// (the prop is undeletable so a userscript can't fool us on reinjection)
+defineProperty(window, process.env.INIT_FUNC_NAME, { value: 1 });
 
-const { document } = global;
+const { document, setTimeout } = global;
 // Userscripts in content mode may redefine head and documentElement
-const { get: getHead } = describeProperty(DocProto, 'head');
-const { get: getDocElem } = describeProperty(DocProto, 'documentElement');
-const { appendChild } = DocProto; // same as Node.appendChild
+const { get: getHead } = describeProperty(Document.prototype, 'head');
+const { get: getDocElem } = describeProperty(Document.prototype, 'documentElement');
+const { appendChild } = Document.prototype; // same as Node.appendChild
 
 export function appendToRoot(node) {
   // DOM spec allows any elements under documentElement
@@ -62,10 +62,12 @@ export function injectScripts(contentId, webId, data, isXml) {
     },
   };
   const triage = (script) => {
-    const { custom, meta } = script;
+    const { custom, dataKey, meta } = script;
     const desiredRealm = custom.injectInto || meta.injectInto || data.injectInto;
     const internalRealm = INJECT_MAPPING[desiredRealm] || INJECT_MAPPING[INJECT_AUTO];
     const realm = internalRealm.find(key => realms[key]?.injectable());
+    // If the script wants this specific realm, which is unavailable, we won't inject it at all
+    if (!realm) return [dataKey, 'done'];
     const { ids, lists } = realms[realm];
     let runAt = bornReady ? 'start'
       : `${custom.runAt || meta.runAt || ''}`.replace(/^document-/, '');
@@ -76,7 +78,7 @@ export function injectScripts(contentId, webId, data, isXml) {
     script.action = action;
     ids::push(script.props.id);
     list::push(script);
-    return [script.dataKey, action];
+    return [dataKey, action];
   };
   const feedback = data.scripts.map(triage);
   setupContentInvoker(realms, contentId, webId);

+ 7 - 48
src/injected/utils/helpers.js

@@ -2,31 +2,22 @@
 import { numberToString } from '#/common';
 import { assign, objectKeys } from '#/common/object';
 
-// Firefox sucks: `isFinite` is not defined on `window`, see violentmonkey/violentmonkey#300
-// eslint-disable-next-line no-restricted-properties
-export const {
-  // types
-  Blob, Boolean, Error, Promise, Uint8Array,
-  // props and methods
-  atob, isFinite, setTimeout,
-} = global;
+export const { Promise } = global;
 
-export const {
-  concat, filter, findIndex, forEach, includes, indexOf, join, map, push,
-  // arraySlice, // to differentiate from String::slice which we use much more often
-} = Array.prototype;
+export const { filter, forEach, includes, join, map, push } = Array.prototype;
 export const { charCodeAt, slice, replace } = String.prototype;
 export const { toString: objectToString } = Object.prototype;
-export const { fromCharCode } = String;
 export const { addEventListener, removeEventListener } = EventTarget.prototype;
 export const { append, remove, setAttribute } = Element.prototype;
-export const DocProto = Document.prototype;
-export const { createElementNS } = DocProto;
+export const { createElementNS } = Document.prototype;
 export const logging = assign({}, console);
 
 export const NS_HTML = 'http://www.w3.org/1999/xhtml';
 
-export const isArray = obj => (
+// Firefox defines `isFinite` on `global` not on `window`
+const { Boolean, Uint8Array, isFinite } = global; // eslint-disable-line no-restricted-properties
+const { fromCharCode } = String;
+const isArray = obj => (
   // ES3 way, not reliable if prototype is modified
   // Object.prototype.toString.call(obj) === '[object Array]'
   // #565 steamcommunity.com has overridden `Array.prototype`
@@ -34,38 +25,6 @@ export const isArray = obj => (
   obj && typeof obj.length === 'number' && typeof obj.splice === 'function'
 );
 
-export function noop() {}
-
-/**
- * http://www.webtoolkit.info/javascript-utf8.html
- */
-export function utf8decode(utftext) {
-  /* eslint-disable no-bitwise */
-  let string = '';
-  let i = 0;
-  let c1 = 0;
-  let c2 = 0;
-  let c3 = 0;
-  while (i < utftext.length) {
-    c1 = utftext::charCodeAt(i);
-    if (c1 < 128) {
-      string += fromCharCode(c1);
-      i += 1;
-    } else if (c1 > 191 && c1 < 224) {
-      c2 = utftext::charCodeAt(i + 1);
-      string += fromCharCode(((c1 & 31) << 6) | (c2 & 63));
-      i += 2;
-    } else {
-      c2 = utftext::charCodeAt(i + 1);
-      c3 = utftext::charCodeAt(i + 2);
-      string += fromCharCode(((c1 & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
-      i += 3;
-    }
-  }
-  return string;
-  /* eslint-enable no-bitwise */
-}
-
 // Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON#Polyfill
 const escMap = {
   '"': '\\"',

+ 2 - 2
src/injected/web/bridge.js

@@ -1,12 +1,12 @@
 import { getUniqId } from '#/common';
 import { assign } from '#/common/object';
-import { noop, Promise } from '../utils/helpers';
+import { Promise } from '../utils/helpers';
 
 const handlers = {};
 const callbacks = {};
 const bridge = {
   callbacks,
-  load: noop,
+  load: () => {},
   addHandlers(obj) {
     assign(handlers, obj);
   },

+ 75 - 43
src/injected/web/gm-api.js

@@ -1,7 +1,6 @@
-import { cache2blobUrl, dumpScriptValue, getUniqId, isEmpty } from '#/common';
-import { downloadBlob } from '#/common/download';
+import { dumpScriptValue, getUniqId, isEmpty } from '#/common/util';
 import {
-  defineProperty, objectEntries, objectKeys, objectPick, objectValues,
+  assign, defineProperty, describeProperty, objectEntries, objectKeys, objectPick, objectValues,
 } from '#/common/object';
 import bridge from './bridge';
 import store from './store';
@@ -12,12 +11,21 @@ import {
   decodeValue, dumpValue, loadValues, changeHooks,
 } from './gm-values';
 import {
-  atob, findIndex, indexOf, jsonDump, logging, slice, utf8decode, Error,
+  charCodeAt, jsonDump, logging, slice,
+  append, createElementNS, remove, setAttribute, NS_HTML,
 } from '../utils/helpers';
 
-const { getElementById } = Document.prototype;
-const { lastIndexOf } = String.prototype;
-
+const {
+  atob, setTimeout,
+  Blob, Error, TextDecoder, Uint8Array,
+  Array: { prototype: { findIndex, indexOf } },
+  Document: { prototype: { getElementById } },
+  HTMLElement: { prototype: { click } },
+  String: { prototype: { lastIndexOf } },
+  TextDecoder: { prototype: { decode: tdDecode } },
+  URL: { createObjectURL, revokeObjectURL },
+} = global;
+const { get: getDocElem } = describeProperty(Document.prototype, 'documentElement');
 const vmOwnFuncToString = () => '[Violentmonkey property]';
 export const vmOwnFunc = (func, toString) => {
   defineProperty(func, 'toString', { value: toString || vmOwnFuncToString });
@@ -90,30 +98,10 @@ export function makeGmApi() {
       if (isEmpty(keyHooks)) delete changeHooks[this.id];
     },
     GM_getResourceText(name) {
-      if (name in this.resources) {
-        const key = this.resources[name];
-        const raw = store.cache[this.pathMap[key] || key];
-        if (!raw) return;
-        const i = raw::lastIndexOf(',');
-        const lastPart = i < 0 ? raw : raw::slice(i + 1);
-        return utf8decode(atob(lastPart));
-      }
+      return getResource(this, name);
     },
     GM_getResourceURL(name) {
-      if (name in this.resources) {
-        const key = this.resources[name];
-        let blobUrl = this.urls[key];
-        if (!blobUrl) {
-          const raw = store.cache[this.pathMap[key] || key];
-          if (raw) {
-            blobUrl = cache2blobUrl(raw);
-            this.urls[key] = blobUrl;
-          } else {
-            blobUrl = key;
-          }
-        }
-        return blobUrl;
-      }
+      return getResource(this, name, true);
     },
     GM_registerMenuCommand(cap, func) {
       const { id } = this;
@@ -129,25 +117,24 @@ export function makeGmApi() {
       bridge.post('UnregisterMenu', [id, cap]);
     },
     GM_download(arg1, name) {
-      const opts = typeof arg1 === 'string' ? { url: arg1, name } : arg1;
-      if (!opts || !opts.url) throw new Error('GM_download: Invalid parameter!');
-      return onRequestCreate({
+      // not using ... as it calls Babel's polyfill that calls unsafe Object.xxx
+      const opts = assign({
         method: 'GET',
         responseType: 'blob',
         overrideMimeType: 'application/octet-stream',
-        onload: res => downloadBlob(res.response, opts.name, () => opts.onload?.(res)),
-        ...objectPick(opts, [
-          'url',
-          'headers',
-          'timeout',
-          'onerror',
-          'onprogress',
-          'ontimeout',
-        ]),
-      }, this.id);
+        onload: downloadBlob,
+      }, objectPick(typeof arg1 === 'string' ? { url: arg1, name } : arg1, [
+        'url',
+        'headers',
+        'timeout',
+        'onerror',
+        'onprogress',
+        'ontimeout',
+      ]));
+      opts.context = opts;
+      return onRequestCreate(opts, this.id);
     },
     GM_xmlhttpRequest(opts) {
-      if (!opts || !opts.url) throw new Error('GM_xmlhttpRequest: Invalid parameter!');
       return onRequestCreate(opts, this.id);
     },
     GM_addStyle(css) {
@@ -205,3 +192,48 @@ export function makeGmApi() {
     addStyle: true, // gm4-polyfill.js sets it anyway
   }];
 }
+
+function getResource(context, name, isBlob) {
+  const key = context.resources[name];
+  if (key) {
+    let res = isBlob && context.urls[key];
+    if (!res) {
+      const raw = store.cache[context.pathMap[key] || key];
+      if (raw) {
+        const dataPos = raw::lastIndexOf(',');
+        const bin = atob(dataPos < 0 ? raw : raw::slice(dataPos + 1));
+        const len = bin.length;
+        const bytes = new Uint8Array(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 Blob([bytes], { type }));
+          context.urls[key] = res;
+        } else {
+          res = new TextDecoder()::tdDecode(bytes);
+        }
+      } else if (isBlob) {
+        res = key;
+      }
+    }
+    return res;
+  }
+}
+
+function downloadBlob(res) {
+  const { context: { name, onload }, response } = res;
+  const url = createObjectURL(response);
+  const a = document::createElementNS(NS_HTML, 'a');
+  a::setAttribute('hidden', '');
+  a::setAttribute('href', url);
+  if (name) a::setAttribute('download', name);
+  document::getDocElem()::append(a);
+  a::click();
+  setTimeout(() => {
+    a::remove(a);
+    revokeObjectURL(url);
+    onload?.(res);
+  }, 3000);
+}

+ 171 - 119
src/injected/web/gm-wrapper.js

@@ -1,34 +1,33 @@
-import { hasOwnProperty as has } from '#/common';
+import { hasOwnProperty } from '#/common';
 import { INJECT_CONTENT } from '#/common/consts';
-import { defineProperty, describeProperty, objectKeys } from '#/common/object';
+import { assign, defineProperty, describeProperty, objectKeys } from '#/common/object';
 import bridge from './bridge';
 import {
-  concat, filter, forEach, includes, indexOf, map, push, slice,
+  filter, forEach, includes, map, slice,
   replace, addEventListener, removeEventListener,
 } from '../utils/helpers';
 import { makeGmApi, vmOwnFunc } from './gm-api';
 
-const { Proxy } = global;
-const { getOwnPropertyNames, getOwnPropertySymbols } = Object;
-const { splice } = Array.prototype;
-const { startsWith } = String.prototype;
+const {
+  Proxy,
+  Set, // 2x-3x faster lookup than object::has
+  Symbol: { toStringTag, iterator: iterSym },
+  Array: { prototype: { concat, slice: arraySlice } },
+  Function: { prototype: { bind } }, // function won't be stepped-into when debugging
+  Map: { prototype: { get: mapGet, has: mapHas, [iterSym]: mapIter } },
+  Set: { prototype: { delete: setDelete, has: setHas, [iterSym]: setIter } },
+  Object: { getOwnPropertyNames, getOwnPropertySymbols },
+  String: { prototype: { startsWith } },
+} = global;
 
 let gmApi;
 let gm4Api;
 let componentUtils;
 let windowClose;
-const { toStringTag } = Symbol;
 const vmSandboxedFuncToString = nativeFunc => () => (
   `${nativeFunc}`::replace('native code', 'Violentmonkey sandbox')
 );
 
-export function deletePropsCache() {
-  // let GC sweep the no longer necessary stuff
-  gmApi = null;
-  gm4Api = null;
-  componentUtils = null;
-}
-
 export function wrapGM(script) {
   // Add GM functions
   // Reference: http://wiki.greasespot.net/Greasemonkey_Manual:API
@@ -39,15 +38,17 @@ export function wrapGM(script) {
   const id = script.props.id;
   const resources = script.meta.resources || {};
   const gmInfo = makeGmInfo(script, resources);
-  const gm = {
-    GM: { info: gmInfo },
-    GM_info: gmInfo,
-    unsafeWindow: global,
-    ...componentUtils || (componentUtils = makeComponentUtils()),
-    ...grant::includes('window.close') && windowClose || (windowClose = {
+  const gm = assign( // not using ... as it calls Babel's polyfill that calls unsafe Object.xxx
+    {
+      GM: { info: gmInfo },
+      GM_info: gmInfo,
+      unsafeWindow: global,
+    },
+    componentUtils || (componentUtils = makeComponentUtils()),
+    grant::includes('window.close') && windowClose || (windowClose = {
       close: vmOwnFunc(() => bridge.post('TabClose')),
     }),
-  };
+  );
   const context = {
     id,
     script,
@@ -78,12 +79,13 @@ function makeGmInfo(script, resources) {
     scriptHandler: 'Violentmonkey',
     version: process.env.VM_VER,
     injectInto: bridge.mode,
-    platform: { ...bridge.ua },
+    platform: assign({}, bridge.ua),
     script: {
       description: meta.description || '',
-      excludes: [...meta.exclude],
-      includes: [...meta.include],
-      matches: [...meta.match],
+      // using ::slice since array spreading can be broken via Array.prototype[Symbol.iterator]
+      excludes: meta.exclude::arraySlice(),
+      includes: meta.include::arraySlice(),
+      matches: meta.match::arraySlice(),
       name: meta.name || '',
       namespace: meta.namespace || '',
       resources: objectKeys(resources)::map(name => ({
@@ -102,66 +104,107 @@ function makeGmMethodCaller(gmMethod, context, isAsync) {
   return gmMethod === gmApi.GM_log ? gmMethod : vmOwnFunc(
     isAsync
       ? (async (...args) => context::gmMethod(...args))
-      : ((...args) => context::gmMethod(...args)),
+      : gmMethod::bind(context),
   );
 }
 
-// https://html.spec.whatwg.org/multipage/window-object.html#the-window-object
-// https://w3c.github.io/webappsec-secure-contexts/#monkey-patching-global-object
-// https://compat.spec.whatwg.org/#windoworientation-interface
-const readonlyGlobals = [
+const globalKeys = getOwnPropertyNames(window).filter(key => !isFrameIndex(key, true));
+/* Chrome and FF page mode: `global` is `window`
+   FF content mode: `global` is different, some props e.g. `isFinite` are defined only there */
+if (global !== window) {
+  const set = new Set(globalKeys);
+  getOwnPropertyNames(global).forEach(key => {
+    if (!isFrameIndex(key) && !set.has(key)) {
+      globalKeys.push(key);
+    }
+  });
+}
+const inheritedKeys = new Set([
+  ...getOwnPropertyNames(EventTarget.prototype),
+  ...getOwnPropertyNames(Object.prototype),
+]);
+inheritedKeys.has = setHas;
+
+/* These can be redefined but can't be assigned, see sandbox-globals.html */
+const readonlyKeys = [
   'applicationCache',
+  'caches',
   'closed',
+  'crossOriginIsolated',
+  'crypto',
   'customElements',
   'frameElement',
   'history',
+  'indexedDB',
   'isSecureContext',
+  'localStorage',
+  'mozInnerScreenX',
+  'mozInnerScreenY',
   'navigator',
-  'orientation',
+  'sessionStorage',
+  'speechSynthesis',
   'styleMedia',
-];
-// https://html.spec.whatwg.org/multipage/window-object.html
-// https://w3c.github.io/webappsec-trusted-types/dist/spec/#extensions-to-the-window-interface
-const unforgeableGlobals = [
+  'trustedTypes',
+].filter(key => key in global); // not using global[key] as some of these (caches) may throw
+
+/* These can't be redefined, see sandbox-globals.html */
+const unforgeables = new Map([
+  'Infinity',
+  'NaN',
   'document',
   'location',
   'top',
-  'trustedTypes',
+  'undefined',
   'window',
-];
-// 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 isUnforgeableFrameIndex = name => typeof name !== 'symbol' && /^(0|[1-9]\d+)$/.test(name);
-// These can't run with an arbitrary object in `this` such as our wrapper
-// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects
-// https://developer.mozilla.org/docs/Web/API/Window
-const boundGlobals = [
+].map(name => {
+  let thisObj;
+  const info = (
+    describeProperty(thisObj = global, name)
+    || describeProperty(thisObj = window, name)
+  );
+  // currently only one key is bound: `document`
+  if (info?.get) info.get = info.get::bind(thisObj);
+  return info && [name, info];
+}).filter(Boolean));
+unforgeables.has = mapHas;
+unforgeables[iterSym] = mapIter;
+
+/* ~50 methods like alert/fetch/moveBy that need `window` as `this`, see sandbox-globals.html */
+const boundMethods = new Map([
   'addEventListener',
   'alert',
   'atob',
   'blur',
   'btoa',
+  'cancelAnimationFrame',
+  'cancelIdleCallback',
+  'captureEvents',
   'clearInterval',
   'clearTimeout',
   'close',
   'confirm',
+  'createImageBitmap',
   'dispatchEvent',
+  'dump',
   'fetch',
   'find',
   'focus',
   'getComputedStyle',
-  'getDefaultComputedStyle', // Non-standard, Firefox only, used by jQuery
+  'getDefaultComputedStyle',
   'getSelection',
   'matchMedia',
   'moveBy',
   'moveTo',
   'open',
-  'openDialog',
+  'openDatabase',
   'postMessage',
   'print',
   'prompt',
+  'queueMicrotask',
+  'releaseEvents',
   'removeEventListener',
   'requestAnimationFrame',
+  'requestIdleCallback',
   'resizeBy',
   'resizeTo',
   'scroll',
@@ -170,23 +213,43 @@ const boundGlobals = [
   'scrollByPages',
   'scrollTo',
   'setInterval',
+  'setResizable',
   'setTimeout',
+  'sizeToContent',
   'stop',
-];
-const boundGlobalsRunner = (func, thisArg) => (...args) => thisArg::func(...args);
+  'updateCommands',
+  'webkitCancelAnimationFrame',
+  'webkitRequestAnimationFrame',
+  'webkitRequestFileSystem',
+  'webkitResolveLocalFileSystemURL',
+]
+.map((key) => {
+  const value = global[key];
+  return typeof value === 'function' && [
+    key,
+    vmOwnFunc(value::bind(global), vmSandboxedFuncToString(value)),
+  ];
+})
+.filter(Boolean));
+boundMethods.get = mapGet;
+
 /**
  * @desc Wrap helpers to prevent unexpected modifications.
  */
 function makeGlobalWrapper(local) {
   const events = {};
-  const deleted = []; // using an array to skip building it in ownKeys()
   const scopeSym = Symbol.unscopables;
-  /*
-   - Chrome, `global === window`
-   - Firefox, `global` is a sandbox, `global.window === window`:
-     - some properties (like `isFinite`) are defined in `global` but not `window`
-     - all `window` properties can be accessed from `global`
-  */
+  const globals = new Set(globalKeys);
+  globals[iterSym] = setIter;
+  globals.delete = setDelete;
+  globals.has = setHas;
+  const readonlys = new Set(readonlyKeys);
+  readonlys.delete = setDelete;
+  readonlys.has = setHas;
+  local.has = hasOwnProperty;
+  for (const [name, desc] of unforgeables) {
+    defineProperty(local, name, desc);
+  }
   if (bridge.isFirefox) {
     // Firefox returns [object Object] so jQuery libs see our `window` proxy as a plain
     // object and try to clone its recursive properties like `self` and `window`.
@@ -194,104 +257,82 @@ function makeGlobalWrapper(local) {
     defineProperty(local, toStringTag, { get: () => 'Window' });
   }
   const wrapper = new Proxy(local, {
-    defineProperty(_, name, info) {
-      if (typeof name !== 'symbol'
-      && (unforgeableGlobals::includes(name) || isUnforgeableFrameIndex(name))) return false;
-      defineProperty(local, name, info);
-      if (typeof name === 'string' && name::startsWith('on')) {
-        setEventHandler(name::slice(2));
+    defineProperty(_, name, desc) {
+      const isString = typeof name === 'string';
+      if (!isFrameIndex(name, isString)) {
+        defineProperty(local, name, desc);
+        if (isString) maybeSetEventHandler(name);
+        readonlys.delete(name);
       }
-      undelete(name);
       return true;
     },
     deleteProperty(_, name) {
-      if (unforgeableGlobals::includes(name)) return false;
-      if (isUnforgeableFrameIndex(name) || deleted::includes(name)) return true;
-      if (global::has(name)) deleted::push(name);
-      return delete local[name];
+      return !unforgeables.has(name)
+        && delete local[name]
+        && globals.delete(name);
     },
     get(_, name) {
-      const value = local[name];
-      return value !== undefined || name === scopeSym || deleted::includes(name) || local::has(name)
-        ? value
-        : resolveProp(name);
+      if (name !== 'undefined' && name !== scopeSym) {
+        const value = local[name];
+        return value !== undefined || local.has(name)
+          ? value
+          : resolveProp(name);
+      }
     },
     getOwnPropertyDescriptor(_, name) {
-      if (!deleted::includes(name)) {
-        const ownDesc = describeProperty(local, name);
-        const desc = ownDesc || describeProperty(global, name);
-        if (!desc) return;
-        if (desc.value === window) desc.value = wrapper;
-        // preventing spec violation by duplicating ~10 props like NaN, Infinity, etc.
-        if (!ownDesc && !desc.configurable) {
-          const { get } = desc;
-          if (typeof get === 'function') {
-            desc.get = (...args) => global::get(...args);
-          }
-          defineProperty(local, name, desc);
-        }
-        return desc;
-      }
+      const ownDesc = describeProperty(local, name);
+      const desc = ownDesc || globals.has(name) && describeProperty(global, name);
+      if (desc && desc.value === window) desc.value = wrapper;
+      return desc;
     },
     has(_, name) {
-      return local::has(name)
-        || !deleted::includes(name) && global::has(name);
+      return name === 'undefined' || local.has(name) || globals.has(name);
     },
     ownKeys() {
-      return []::concat(
-        ...filterGlobals(getOwnPropertyNames),
-        ...filterGlobals(getOwnPropertySymbols),
+      return [...globals]::concat(
+        // using ::concat since array spreading can be broken via Array.prototype[Symbol.iterator]
+        getOwnPropertyNames(local)::filter(notIncludedIn, globals),
+        getOwnPropertySymbols(local)::filter(notIncludedIn, globals),
       );
     },
     preventExtensions() {},
     set(_, name, value) {
-      if (unforgeableGlobals::includes(name)) return false;
-      undelete(name);
-      if (readonlyGlobals::includes(name) || isUnforgeableFrameIndex(name)) return true;
-      local[name] = value;
-      if (typeof name === 'string' && name::startsWith('on') && window::has(name)) {
-        setEventHandler(name::slice(2), value);
+      const isString = typeof name === 'string';
+      if (!readonlys.has(name) && !isFrameIndex(name, isString)) {
+        local[name] = value;
+        if (isString) maybeSetEventHandler(name, value);
       }
       return true;
     },
   });
-  function filterGlobals(describer) {
-    const globalKeys = describer(global);
-    const localKeys = describer(local);
-    return [
-      deleted.length
-        ? globalKeys::filter(key => !deleted::includes(key))
-        : globalKeys,
-      localKeys::filter(key => !globalKeys::includes(key)),
-    ];
-  }
   function resolveProp(name) {
-    let value = global[name];
+    let value = boundMethods.get(name);
+    const canCopy = value || inheritedKeys.has(name) || globals.has(name);
+    if (!value && (canCopy || isFrameIndex(name, typeof name === 'string'))) {
+      value = global[name];
+    }
     if (value === window) {
       value = wrapper;
-    } else if (boundGlobals::includes(name)) {
-      value = vmOwnFunc(
-        boundGlobalsRunner(value, global),
-        vmSandboxedFuncToString(value),
-      );
+    }
+    if (canCopy && (typeof value === 'function' || typeof value === 'object' && value)) {
       local[name] = value;
     }
     return value;
   }
-  function setEventHandler(name, value) {
+  function maybeSetEventHandler(name, value) {
+    if (!name::startsWith('on') || !globals.has(name)) {
+      return;
+    }
+    name = name::slice(2);
     window::removeEventListener(name, events[name]);
     if (typeof value === 'function') {
       // the handler will be unique so that one script couldn't remove something global
       // like console.log set by another script
-      window::addEventListener(name, events[name] = boundGlobalsRunner(value, window));
+      window::addEventListener(name, events[name] = value::bind(window));
     } else {
       delete events[name];
     }
   }
-  function undelete(name) {
-    const i = deleted::indexOf(name);
-    if (i >= 0) deleted::splice(i, 1);
-  }
   return wrapper;
 }
 
@@ -319,3 +360,14 @@ function makeComponentUtils() {
     ),
   };
 }
+
+/* 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 */
+function isFrameIndex(key, isString) {
+  return isString && key >= 0 && key <= 0xFFFF_FFFE && key === `${+key}`;
+}
+
+/** @this {Set} */
+function notIncludedIn(key) {
+  return !this.has(key);
+}

+ 15 - 15
src/injected/web/requests.js

@@ -1,14 +1,14 @@
-import { defineProperty, describeProperty, objectPick } from '#/common/object';
+import { assign, defineProperty, describeProperty, objectPick } from '#/common/object';
 import {
-  filter, includes, map, push, jsonDump, jsonLoad, join, objectToString, Promise, Uint8Array,
+  filter, includes, map, push, jsonDump, jsonLoad, join, objectToString, Promise,
   setAttribute, log, buffer2stringSafe, charCodeAt, slice,
-  createElementNS, NS_HTML, Blob,
+  createElementNS, NS_HTML,
 } from '../utils/helpers';
 import bridge from './bridge';
 
 const idMap = {};
 
-const { DOMParser } = global;
+const { Blob, DOMParser, Error, Uint8Array } = global;
 const { parseFromString } = DOMParser.prototype;
 const { then } = Promise.prototype;
 const { toLowerCase } = String.prototype;
@@ -22,6 +22,7 @@ bridge.addHandlers({
 });
 
 export function onRequestCreate(details, scriptId) {
+  if (!details.url) throw new Error('Required parameter "url" is missing.');
   const req = {
     scriptId,
     details,
@@ -140,20 +141,19 @@ async function start(req, id) {
   // 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 { withCredentials = true, anonymous = !withCredentials } = details;
-  const payload = {
+  const payload = assign({
     id,
     scriptId,
     anonymous,
-    ...objectPick(details, [
-      'headers',
-      'method',
-      'overrideMimeType',
-      'password',
-      'timeout',
-      'url',
-      'user',
-    ]),
-  };
+  }, objectPick(details, [
+    'headers',
+    'method',
+    'overrideMimeType',
+    'password',
+    'timeout',
+    'url',
+    'user',
+  ]));
   req.id = id;
   idMap[id] = req;
   const { responseType } = details;