Просмотр исходного кода

fix: use iframe fallback for safe globals

tophf 4 лет назад
Родитель
Сommit
86887363ae

+ 4 - 6
scripts/sandbox-globals.html

@@ -4,7 +4,7 @@
 <script>
 const el = document.getElementById('results');
 el.value = [
-  function boundMethods(isHeader) {
+  function boundMethods() {
     const keys = [];
     /* global globalThis */
     for (const k in globalThis) {
@@ -53,11 +53,9 @@ el.value = [
   __proto__: null,
 ${
   res.keys.sort()
-  .map(k => `  ${k}: ${res.value || 1},`)
-  .join('\n')
-}
-}
-`;
+  .map(k => `  ${k}: ${res.value || 1},\n`)
+  .join('')
+}};\n`;
 }).join('\n');
 
 el.focus();

+ 100 - 39
src/injected/content/inject.js

@@ -1,5 +1,5 @@
 import bridge from './bridge';
-import { appendToRoot, makeElem, onElement, sendCmd } from './util-content';
+import { elemByTag, makeElem, onElement, sendCmd } from './util-content';
 import {
   bindEvents, fireBridgeEvent,
   INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser,
@@ -12,6 +12,8 @@ import {
 const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
 const VM_UUID = browser.runtime.getURL('');
 const VAULT_WRITER = `${VM_UUID}VW`;
+const VAULT_WRITE_ACK = `${VAULT_WRITER}+`;
+const DISPLAY_NONE = 'display:none!important';
 let contLists;
 let pgLists;
 /** @type {Object<string,VMInjectionRealm>} */
@@ -42,6 +44,7 @@ if (IS_FIREFOX) {
     } else {
       // setupVaultId's second event is the vaultId
       tellBridgeToWriteVault(evt::getDetail(), frameEventWnd);
+      frameEventWnd::fire(new CustomEventSafe(VAULT_WRITE_ACK));
       frameEventWnd = null;
     }
   }, true);
@@ -60,34 +63,52 @@ bridge.addHandlers({
 
 export function injectPageSandbox(contentId, webId) {
   const { cloneInto } = global;
-  /* A page can read our script's textContent in a same-origin iframe via DOMNodeRemoved event.
+  const isSpoofable = document.referrer.startsWith(`${window.location.origin}/`);
+  const vaultId = isSpoofable && getUniqIdSafe();
+  const handshakeId = getUniqIdSafe();
+  if (isSpoofable && (
+    !WriteVaultFromOpener(window.opener, vaultId)
+    && !WriteVaultFromOpener(!IS_TOP && window.parent, vaultId)
+  )) {
+    /* Sites can do window.open(sameOriginUrl,'iframeNameOrNewWindowName').opener=null, spoof JS
+     * environment and easily hack into our communication channel before our content scripts run.
+     * Content scripts will see `document.opener = null`, not the original opener, so we have
+     * to use an iframe to extract the safe globals. */
+    inject({ code: `parent["${vaultId}"] = [this]` }, () => {
+      // Skipping page injection in FF if our script element was blocked by site's CSP
+      if (!IS_FIREFOX || window.wrappedJSObject[vaultId]) {
+        startHandshake();
+      }
+      contentId = false;
+    });
+  }
+  if (contentId) {
+    startHandshake();
+  }
+  return pageInjectable;
+
+  /** A page can read our script's textContent in a same-origin iframe via DOMNodeRemoved event.
    * Directly preventing it would require redefining ~20 DOM methods in the parent.
    * Instead, we'll send the ids via a temporary handshakeId event, to which the web-bridge
-   * will listen only during its initial phase using vault-protected DOM methods. */
-  const handshakeId = getUniqIdSafe();
-  const handshaker = evt => {
+   * will listen only during its initial phase using vault-protected DOM methods.
+   * TODO: simplify this when strict_min_version >= 63 (attachShadow in FF) */
+  function startHandshake() {
+    /* With `once` the listener is removed before DOMNodeInserted is dispatched by appendChild,
+     * otherwise a same-origin parent page could use it to spoof the handshake. */
+    window::on(handshakeId, handshaker, { capture: true, once: true });
+    inject({
+      code: `(${VMInitInjection}(${IS_FIREFOX},'${handshakeId}','${vaultId}'))()`
+        + `\n//# sourceURL=${VM_UUID}sandbox/injected-web.js`,
+    });
+    // Clean up in case CSP prevented the script from running
+    window::off(handshakeId, handshaker, true);
+  }
+  function handshaker(evt) {
     pageInjectable = true;
     evt::stopImmediatePropagation();
     bindEvents(contentId, webId, bridge, cloneInto);
     fireBridgeEvent(handshakeId + process.env.HANDSHAKE_ACK, [webId, contentId], cloneInto);
-  };
-  /* The vault contains safe methods that we got from the highest same-origin parent,
-   * where our code ran at document_start so it definitely predated the page scripts. */
-  const parent = window.opener || !IS_TOP && window.parent;
-  const vaultId = parent
-    && isSameOriginWindow(parent)
-    && tellParentToWriteVault(parent, getUniqIdSafe())
-    || '';
-  /* With `once` the listener is removed before DOMNodeInserted is dispatched by appendChild,
-   * otherwise a same-origin parent page could use it to spoof the handshake. */
-  window::on(handshakeId, handshaker, { capture: true, once: true });
-  inject({
-    code: `(${VMInitInjection}(${IS_FIREFOX},'${handshakeId}','${vaultId}'))()`
-      + `\n//# sourceURL=${VM_UUID}sandbox/injected-web.js`,
-  });
-  // Clean up in case CSP prevented the script from running
-  window::off(handshakeId, handshaker, true);
-  return pageInjectable;
+  }
 }
 
 /**
@@ -192,11 +213,16 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }) {
   injectAll('idle');
 }
 
-function inject(item) {
+function inject(item, iframeCb) {
+  const root = elemByTag('*');
+  // In Chrome injectPageSandbox calls inject() another time while the first one still runs
+  const isAdded = root && elShadow && root::elemByTag('*') === elShadow;
   const realScript = makeElem('script', item.code);
-  let script = realScript;
-  // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
+  let el = realScript;
   let onError;
+  let iframe;
+  let iframeLoader;
+  // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
   if (IS_FIREFOX) {
     onError = e => {
       const { stack } = e.error;
@@ -212,16 +238,44 @@ function inject(item) {
     if (!elShadow) {
       elShadow = makeElem('div');
       elShadowRoot = elShadow::attachShadow({ mode: 'closed' });
-      elShadowRoot::appendChild(makeElem('style', ':host { display: none !important }'));
+      elShadowRoot::appendChild(makeElem('style', `:host { ${DISPLAY_NONE} }`));
+      if (iframeCb) {
+        iframe = makeElem('iframe', {
+          /* Preventing other content scripts in Chrome */// eslint-disable-next-line no-script-url
+          src: 'javascript:void 0',
+          sandbox: 'allow-scripts allow-same-origin',
+          style: DISPLAY_NONE,
+        });
+        iframeLoader = () => {
+          iframeLoader = null;
+          iframe.contentDocument::getElementsByTagName('*')[0]::appendChild(realScript);
+          iframeCb();
+          realScript::remove();
+          iframe::remove();
+        };
+        /* In Chrome, when an empty iframe is inserted into document, `load` is fired synchronously,
+         * then `DOMNodeInserted`, also synchronously, then appendChild finishes, then our
+         * subsequent code runs. So, we have to use `load` event to run our script,
+         * otherwise it can be easily intercepted via DOMNodeInserted. */
+        if (!IS_FIREFOX) {
+          iframe::on('load', iframeLoader, { once: true });
+        }
+        elShadowRoot::appendChild(iframe);
+      }
+    }
+    if (!iframe) {
+      elShadowRoot::appendChild(realScript);
     }
-    elShadowRoot::appendChild(realScript);
-    script = elShadow;
+    el = elShadow;
   }
   // When using declarativeContent there's no documentElement so we'll append to `document`
-  if (!appendToRoot(script)) document::appendChild(script);
+  if (!isAdded) (root || document)::appendChild(el);
+  if (iframeLoader) iframeLoader();
   if (onError) window::off('error', onError);
+  // Clean up in case something didn't load
+  if (iframe) iframe::remove();
   if (attachShadow) realScript::remove();
-  script::remove();
+  el::remove();
 }
 
 function injectAll(runAt) {
@@ -265,15 +319,22 @@ function setupContentInvoker(contentId, webId) {
   };
 }
 
-function tellParentToWriteVault(parent, vaultId) {
-  // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
-  if (IS_FIREFOX) {
-    parent::fire(new MouseEventSafe(VAULT_WRITER, { relatedTarget: window }));
-    parent::fire(new CustomEventSafe(VAULT_WRITER, { detail: vaultId }));
-  } else {
-    parent[VAULT_WRITER](vaultId, window);
+function WriteVaultFromOpener(opener, vaultId) {
+  let ok;
+  if (opener && describeProperty(opener.location, 'href').get) {
+    // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
+    if (IS_FIREFOX) {
+      const setOk = () => { ok = true; };
+      window::on(VAULT_WRITE_ACK, setOk, true);
+      opener::fire(new MouseEventSafe(VAULT_WRITER, { relatedTarget: window }));
+      opener::fire(new CustomEventSafe(VAULT_WRITER, { detail: vaultId }));
+      window::off(VAULT_WRITE_ACK, setOk, true);
+    } else {
+      ok = opener[VAULT_WRITER];
+      if (ok) ok(vaultId, window);
+    }
   }
-  return vaultId;
+  return ok;
 }
 
 function tellBridgeToWriteVault(vaultId, wnd) {

+ 0 - 7
src/injected/content/util-content.js

@@ -6,13 +6,6 @@ export { sendCmd } from '#/common';
  * as it searches for ALL matching nodes when this tag wasn't cached internally. */
 export const elemByTag = tag => getOwnProp(document::getElementsByTagName(tag), 0);
 
-export const appendToRoot = node => {
-  // DOM spec allows any elements under documentElement
-  // https://dom.spec.whatwg.org/#node-trees
-  const root = elemByTag('head') || elemByTag('*');
-  return root && root::appendChild(node);
-};
-
 /**
  * @param {string} tag
  * @param {function} cb - callback runs immediately, unlike a chained then()

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

@@ -69,11 +69,6 @@ export const vmOwnFunc = (func, toString) => (
   })
 );
 
-/** @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()}`;
 

+ 33 - 26
src/injected/web/safe-globals-web.js

@@ -83,33 +83,40 @@ export const VAULT = (() => {
   let StringP;
   let i = -1;
   let res;
+  let src = window;
+  let srcFF;
   if (process.env.VAULT_ID) {
     res = window[process.env.VAULT_ID];
     delete window[process.env.VAULT_ID];
   }
   if (!res) {
-    res = { __proto__: null };
+    res = createNullObj();
+  } else if (!isFunction(res[0])) {
+    src = res[0];
+    res = createNullObj();
   }
+  srcFF = global === window ? src : global;
   res = [
     // 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
-    ResponseSafe = res[i += 1] || window.Response,
-    fire = res[i += 1] || window.dispatchEvent,
-    off = res[i += 1] || window.removeEventListener,
-    on = res[i += 1] || window.addEventListener,
-    openWindow = res[i += 1] || window.open,
+    BlobSafe = res[i += 1] || src.Blob,
+    CustomEventSafe = res[i += 1] || src.CustomEvent,
+    DOMParserSafe = res[i += 1] || src.DOMParser,
+    ErrorSafe = res[i += 1] || src.Error,
+    FileReaderSafe = res[i += 1] || src.FileReader,
+    KeyboardEventSafe = res[i += 1] || src.KeyboardEvent,
+    MouseEventSafe = res[i += 1] || src.MouseEvent,
+    Object = res[i += 1] || src.Object,
+    PromiseSafe = res[i += 1] || src.Promise,
+    // In FF content mode global.Proxy !== window.Proxy
+    ProxySafe = res[i += 1] || srcFF.Proxy,
+    ResponseSafe = res[i += 1] || src.Response,
+    fire = res[i += 1] || src.dispatchEvent,
+    off = res[i += 1] || src.removeEventListener,
+    on = res[i += 1] || src.addEventListener,
+    openWindow = res[i += 1] || src.open,
     // Symbol
-    scopeSym = res[i += 1] || Symbol.unscopables,
-    toStringTag = res[i += 1] || Symbol.toStringTag,
+    scopeSym = res[i += 1] || srcFF.Symbol.unscopables,
+    toStringTag = res[i += 1] || srcFF.Symbol.toStringTag,
     // Object
     describeProperty = res[i += 1] || Object.getOwnPropertyDescriptor,
     defineProperty = res[i += 1] || Object.defineProperty,
@@ -124,32 +131,32 @@ export const VAULT = (() => {
     hasOwnProperty = res[i += 1] || Object[PROTO].hasOwnProperty,
     objectToString = res[i += 1] || Object[PROTO].toString,
     // Array.prototype
-    concat = res[i += 1] || (ArrayP = Array[PROTO]).concat,
+    concat = res[i += 1] || (ArrayP = src.Array[PROTO]).concat,
     filter = res[i += 1] || ArrayP.filter,
     forEach = res[i += 1] || ArrayP.forEach,
     indexOf = res[i += 1] || ArrayP.indexOf,
     // Element.prototype
-    remove = res[i += 1] || (ElementP = Element[PROTO]).remove,
+    remove = res[i += 1] || (ElementP = src.Element[PROTO]).remove,
     // String.prototype
-    charCodeAt = res[i += 1] || (StringP = String[PROTO]).charCodeAt,
+    charCodeAt = res[i += 1] || (StringP = srcFF.String[PROTO]).charCodeAt,
     slice = res[i += 1] || StringP.slice,
     replace = res[i += 1] || StringP.replace,
     // safeCall
     safeCall = res[i += 1] || Object.call.bind(Object.call),
     // various methods
-    createObjectURL = res[i += 1] || URL.createObjectURL,
+    createObjectURL = res[i += 1] || src.URL.createObjectURL,
     funcToString = res[i += 1] || safeCall.toString,
-    jsonParse = res[i += 1] || JSON.parse,
+    jsonParse = res[i += 1] || src.JSON.parse,
     logging = res[i += 1] || assign({ __proto__: null }, console),
-    mathRandom = res[i += 1] || Math.random,
+    mathRandom = res[i += 1] || srcFF.Math.random,
     parseFromString = res[i += 1] || DOMParserSafe[PROTO].parseFromString,
     readAsDataURL = res[i += 1] || FileReaderSafe[PROTO].readAsDataURL,
     safeResponseBlob = res[i += 1] || ResponseSafe[PROTO].blob,
-    stopImmediatePropagation = res[i += 1] || Event[PROTO].stopImmediatePropagation,
+    stopImmediatePropagation = res[i += 1] || src.Event[PROTO].stopImmediatePropagation,
     then = res[i += 1] || PromiseSafe[PROTO].then,
     // various getters
     getBlobType = res[i += 1] || describeProperty(BlobSafe[PROTO], 'type').get,
-    getCurrentScript = res[i += 1] || describeProperty(Document[PROTO], 'currentScript').get,
+    getCurrentScript = res[i += 1] || describeProperty(src.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,

+ 10 - 14
yarn.lock

@@ -1079,12 +1079,13 @@
     "@nodelib/fs.scandir" "2.1.3"
     fastq "^1.6.0"
 
-"@types/chrome@0.0.101":
-  version "0.0.101"
-  resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.101.tgz#8b6f7d4f1d4890ba7d950f8492725fa7ba9ab910"
-  integrity sha512-9GpAt3fBVPdzEIwdgDrxqCaURyJqOVz+oIWB+iDE2FD0ZeGQhkMwTqJIW+Ok/lnie6tt6DwVMZkOS8x6QkmWsQ==
+"@types/chrome@^0":
+  version "0.0.163"
+  resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.163.tgz#6ff2fc9e8a160ea0655f6c62b4c2210757c42aab"
+  integrity sha512-g+3E2tg/ukFsEgH+tB3a/b+J1VSvq/8gh2Jwih9eq+T3Idrz7ngj97u+/ya58Bfei2TQtPlRivj1FsCaSnukDA==
   dependencies:
     "@types/filesystem" "*"
+    "@types/har-format" "*"
 
 "@types/color-name@^1.1.1":
   version "1.1.1"
@@ -1122,6 +1123,11 @@
     "@types/minimatch" "*"
     "@types/node" "*"
 
+"@types/har-format@*":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.8.tgz#e6908b76d4c88be3db642846bb8b455f0bfb1c4e"
+  integrity sha512-OP6L9VuZNdskgNN3zFQQ54ceYD8OLq5IbqO4VK91ORLfOm7WdT/CiT/pHEBSQEqCInJ2y3O6iCm/zGtPElpgJQ==
+
 "@types/minimatch@*":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -1366,11 +1372,6 @@ acorn@^7.1.1:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf"
   integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==
 
-acorn@^8.4.1:
-  version "8.4.1"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c"
-  integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==
-
 aggregate-error@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0"
@@ -2682,11 +2683,6 @@ concat-stream@^1.5.0, concat-stream@^1.6.0:
     readable-stream "^2.2.2"
     typedarray "^0.0.6"
 
-confusing-browser-globals@^1.0.10:
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59"
-  integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==
-
 confusing-browser-globals@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz#72bc13b483c0276801681871d4898516f8f54fdd"