Browse Source

feat: synchronous injection via XHR

+ fix early injection of iframe
tophf 4 years ago
parent
commit
c624932783

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

@@ -500,6 +500,17 @@ labelViewSingleColumn:
 labelViewTable:
   description: Label for option in dashboard script list to show the scripts as a table.
   message: Table view
+labelXhrInject:
+  message: Synchronous (not recommended)
+labelXhrInjectHint:
+  message: >-
+    Forcibly runs scripts early at the real `@run-at document-start`.
+    Same as Instant injection mode in Tampermonkey. Enable only if you have a script
+    that needs to run before the page starts loading and currently it's running too late.
+    This mode is using the deprecated synchronous XHR, so in Chrome/Chromium you'll
+    see warnings in devtools console, although you can safely ignore them
+    as the adverse affects are negligible in this case.
+    You can hide the warnings for good by right-clicking one.
 lastSync:
   description: Label for last sync timestamp.
   message: Last sync at $1

+ 37 - 4
src/background/utils/preinject.js

@@ -30,12 +30,14 @@ const cache = initCache({
 const KEY_EXPOSE = 'expose';
 const KEY_INJECT_INTO = 'defaultInjectInto';
 const KEY_IS_APPLIED = 'isApplied';
+const KEY_XHR_INJECT = 'xhrInject';
 const expose = {};
 let isApplied;
 let injectInto;
+let xhrInject;
 hookOptions(onOptionChanged);
 postInitialize.push(() => {
-  for (const key of [KEY_EXPOSE, KEY_INJECT_INTO, KEY_IS_APPLIED]) {
+  for (const key of [KEY_EXPOSE, KEY_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
     onOptionChanged({ [key]: getOption(key) });
   }
 });
@@ -107,6 +109,10 @@ function onOptionChanged(changes) {
       injectInto = normalizeInjectInto(value);
       cache.destroy();
       break;
+    case KEY_XHR_INJECT:
+      toggleXhrInject(value);
+      cache.destroy();
+      break;
     case KEY_IS_APPLIED:
       togglePreinject(value);
       break;
@@ -140,10 +146,24 @@ function togglePreinject(enable) {
   const onOff = `${enable ? 'add' : 'remove'}Listener`;
   const config = enable ? API_CONFIG : undefined;
   browser.webRequest.onSendHeaders[onOff](onSendHeaders, config);
-  browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
+  if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
+    browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
+  }
   cache.destroy();
 }
 
+function toggleXhrInject(enable) {
+  xhrInject = enable;
+  browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived);
+  if (enable) {
+    browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, API_CONFIG, [
+      'blocking',
+      'responseHeaders',
+      browser.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
+    ].filter(Boolean));
+  }
+}
+
 function onSendHeaders({ url, tabId, frameId }) {
   if (!INJECTABLE_TAB_URL_RE.test(url)) return;
   const isTop = !frameId;
@@ -158,7 +178,19 @@ function onSendHeaders({ url, tabId, frameId }) {
 
 /** @param {chrome.webRequest.WebResponseHeadersDetails} info */
 function onHeadersReceived(info) {
-  cache.hit(getKey(info.url, !info.frameId), TIME_AFTER_RECEIVE);
+  const key = getKey(info.url, !info.frameId);
+  const injection = xhrInject && cache.get(key)?.inject;
+  cache.hit(key, TIME_AFTER_RECEIVE);
+  if (injection) {
+    const blobUrl = URL.createObjectURL(new Blob([JSON.stringify(injection)]));
+    const { responseHeaders } = info;
+    responseHeaders.push({
+      name: 'Set-Cookie',
+      value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
+    });
+    setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
+    return { responseHeaders };
+  }
 }
 
 function prepare(key, url, tabId, frameId, forceContent) {
@@ -202,11 +234,12 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
   Object.assign(res, {
     feedback,
     valOpIds: [...data[ENV_VALUE_IDS], ...envDelayed[ENV_VALUE_IDS]],
-    rcsPromise: !isLate && IS_FIREFOX
+    rcsPromise: !isLate && !xhrInject && IS_FIREFOX
       ? registerScriptDataFF(inject, url, !!frameId)
       : null,
   });
   if (more) cache.put(envKey, more);
+  cache.put(cacheKey, res); // necessary for the synchronous onHeadersReceived
   return res;
 }
 

+ 1 - 0
src/common/options-defaults.js

@@ -32,6 +32,7 @@ export default {
   version: null,
   /** @type {'auto' | 'page' | 'content'} */
   defaultInjectInto: INJECT_AUTO,
+  xhrInject: false,
   filters: {
     /** @type {'name' | 'code' | 'all'} */
     searchScope: 'name',

+ 31 - 7
src/injected/content/index.js

@@ -15,10 +15,10 @@ let numBadgesSent = 0;
 let bfCacheWired;
 
 // Make sure to call obj::method() in code that may run after INJECT_CONTENT userscripts
-(async () => {
+async function init() {
   const contentId = getUniqIdSafe();
   const webId = getUniqIdSafe();
-  const dataPromise = sendCmd('GetInjected', {
+  const pageInfo = {
     /* In FF93 sender.url is wrong: https://bugzil.la/1734984,
      * in Chrome sender.url is ok, but location.href is wrong for text selection URLs #:~:text= */
     url: IS_FIREFOX && global.location.href,
@@ -28,11 +28,15 @@ let bfCacheWired;
      * since sites can spoof JS environment and easily impersonate a userscript in `page` mode. */
       || IS_FIREFOX && !isDocumentLoading()
       || !injectPageSandbox(contentId, webId),
-  }, { retry: true });
+  };
+  const xhrData = getXhrInjection();
+  const dataPromise = !xhrData && sendCmd('GetInjected', pageInfo, { retry: true });
   // detecting if browser.contentScripts is usable, it was added in FF59 as well as composedPath
-  const data = IS_FIREFOX && Event[PROTO].composedPath
-    ? await getDataFF(dataPromise)
-    : await dataPromise;
+  const data = xhrData || (
+    IS_FIREFOX && Event[PROTO].composedPath
+      ? await getDataFF(dataPromise)
+      : await dataPromise
+  );
   const { allowCmd } = bridge;
   allowCmd('VaultId', contentId);
   bridge::pickIntoThis(data, [
@@ -52,7 +56,7 @@ let bfCacheWired;
   }
   bridge.onScripts = null;
   sendSetPopup();
-})().catch(IS_FIREFOX && console.error); // Firefox can't show exceptions in content scripts
+}
 
 bridge.addBackgroundHandlers({
   Command(data) {
@@ -95,6 +99,8 @@ bridge.addHandlers({
   UpdateValue: true,
 });
 
+init().catch(IS_FIREFOX && console.error); // Firefox can't show exceptions in content scripts
+
 function throttledSetBadge() {
   const num = runningIds.length;
   if (numBadgesSent < num) {
@@ -115,3 +121,21 @@ async function getDataFF(viaMessaging) {
   delete global.vmData;
   return data;
 }
+
+function getXhrInjection() {
+  try {
+    const quotedKey = `"${process.env.INIT_FUNC_NAME}"`;
+    // Accessing document.cookie may throw due to CSP sandbox
+    const cookieValue = document.cookie.split(`${quotedKey}=`)[1];
+    const blobId = cookieValue && cookieValue.split(';', 1)[0];
+    if (blobId) {
+      document.cookie = `${quotedKey}=0; max-age=0; SameSite=Lax`; // this removes our cookie
+      const xhr = new XMLHttpRequest();
+      const url = `blob:${VM_UUID}${blobId}`;
+      xhr.open('get', url, false); // `false` = synchronous
+      xhr.send();
+      URL.revokeObjectURL(url);
+      return JSON.parse(xhr.response);
+    }
+  } catch { /* NOP */ }
+}

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

@@ -2,7 +2,7 @@ import bridge from './bridge';
 import { elemByTag, makeElem, onElement, sendCmd } from './util-content';
 import {
   bindEvents, fireBridgeEvent,
-  INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser,
+  INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
 } from '../util';
 
 /* In FF, content scripts running in a same-origin frame cannot directly call parent's functions
@@ -10,7 +10,6 @@ import {
  * like VAULT_WRITER to avoid interception by sites that can add listeners for all of our
  * INIT_FUNC_NAME ids even though we change it now with each release. */
 const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
-const VM_UUID = browser.runtime.getURL('');
 const VAULT_WRITER = `${IS_FIREFOX ? VM_UUID : INIT_FUNC_NAME}VW`;
 const VAULT_WRITER_ACK = `${VAULT_WRITER}+`;
 const DISPLAY_NONE = 'display:none!important';
@@ -229,7 +228,7 @@ async function injectDelayedScripts(contentId, webId, { cache, scripts }) {
 function inject(item, iframeCb) {
   const root = elemByTag('*');
   // In Chrome injectPageSandbox calls inject() another time while the first one still runs
-  const isAdded = root && (root === scriptDiv || root::elemByTag('*') === scriptDiv);
+  const isAdded = root && scriptDiv && (root === scriptDiv || elemByTag('*', 1) === scriptDiv);
   const script = makeElem('script', item.code);
   let onError;
   let iframe;
@@ -277,6 +276,8 @@ function inject(item, iframeCb) {
       iframe::on('load', iframeLoader, { once: true });
     }
     scriptDivRoot::appendChild(iframe);
+  } else {
+    scriptDivRoot::appendChild(script);
   }
   if (!isAdded) {
     // When using declarativeContent there's no documentElement so we'll append to `document`

+ 3 - 1
src/injected/content/safe-globals-content.js

@@ -46,4 +46,6 @@ export const getRelatedTarget = describeProperty(MouseEventSafe[PROTO], 'related
 export const getReadyState = describeProperty(Document[PROTO], 'readyState').get;
 export const isDocumentLoading = () => !/^(inter|compl)/::regexpTest(document::getReadyState());
 export const logging = assign(createNullObj(), console);
-export const IS_FIREFOX = !global.chrome.app;
+export const { chrome } = global;
+export const IS_FIREFOX = !chrome.app;
+export const VM_UUID = chrome.runtime.getURL('');

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

@@ -4,7 +4,7 @@ export { sendCmd } from '#/common';
 /** When looking for documentElement, use '*' to also support XML pages
  * Note that we avoid spoofed prototype getters by using hasOwnProperty, and not using `length`
  * 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 elemByTag = (tag, i) => getOwnProp(document::getElementsByTagName(tag), i || 0);
 
 /**
  * @param {string} tag

+ 7 - 1
src/options/views/tab-settings/index.vue

@@ -92,7 +92,7 @@
             </locale-group>
           </label>
         </div>
-        <div>
+        <div class="mr-2c">
           <label>
             <span v-text="i18n('labelInjectionMode')"></span>
             <select v-for="opt in ['defaultInjectInto']" v-model="settings[opt]" :key="opt">
@@ -101,6 +101,12 @@
             </select>
             <a class="ml-1" href="https://violentmonkey.github.io/posts/inject-into-context/" target="_blank" rel="noopener noreferrer" v-text="i18n('learnInjectionMode')"></a>
           </label>
+          <label>
+            <setting-check name="xhrInject"/>
+            <tooltip :content="i18n('labelXhrInjectHint')">
+              <span v-text="i18n('labelXhrInject')"/>
+            </tooltip>
+          </label>
         </div>
         <div>
           <locale-group i18n-key="labelExposeStatus" class="mr-1c">