Преглед изворни кода

feat: inject page sandbox only when needed

tophf пре 3 година
родитељ
комит
a2cde315fa

+ 1 - 0
.eslintrc.js

@@ -16,6 +16,7 @@ const FILES_SHARED = [
 const GLOBALS_COMMON = getGlobals('src/common/safe-globals.js');
 const GLOBALS_INJECTED = getGlobals(`src/injected/safe-globals-injected.js`);
 const GLOBALS_CONTENT = {
+  INIT_FUNC_NAME: false,
   ...getGlobals(`src/injected/content/safe-globals-content.js`),
   ...GLOBALS_INJECTED,
 };

+ 6 - 4
scripts/webpack.conf.js

@@ -85,7 +85,9 @@ const defsObj = {
 };
 // avoid running webpack bootstrap in a potentially hacked environment
 // after documentElement was replaced which triggered reinjection of content scripts
-const skipReinjectionHeader = `if (window['${INIT_FUNC_NAME}'] !== 1)`;
+const skipReinjectionHeader = `{
+  const INIT_FUNC_NAME = '${INIT_FUNC_NAME}';
+  if (window[INIT_FUNC_NAME] !== 1)`;
 
 const modify = (page, entry, init) => modifyWebpackConfig(
   (config) => {
@@ -129,7 +131,7 @@ module.exports = Promise.all([
     config.plugins.push(new ProtectWebpackBootstrapPlugin());
     addWrapperWithGlobals('injected/content', config, defsObj, getGlobals => ({
       header: () => `${skipReinjectionHeader} { ${getGlobals()}`,
-      footer: '}',
+      footer: '}}',
     }));
   }),
 
@@ -139,13 +141,13 @@ module.exports = Promise.all([
     config.plugins.push(new ProtectWebpackBootstrapPlugin());
     addWrapperWithGlobals('injected/web', config, defsObj, getGlobals => ({
       header: () => `${skipReinjectionHeader}
-        window['${INIT_FUNC_NAME}'] = function (IS_FIREFOX,${HANDSHAKE_ID},${VAULT_ID}) {
+        window[INIT_FUNC_NAME] = function (IS_FIREFOX,${HANDSHAKE_ID},${VAULT_ID}) {
           const module = { __proto__: null };
           ${getGlobals()}`,
       footer: `
           const { exports } = module;
           return exports.__esModule ? exports.default : exports;
-        };0;`,
+        }};0;`,
     }));
   }),
 ]);

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

@@ -260,6 +260,7 @@ export async function dumpValueStores(valueDict) {
 
 export const ENV_CACHE_KEYS = 'cacheKeys';
 export const ENV_REQ_KEYS = 'reqKeys';
+export const ENV_SCRIPTS = 'scripts';
 export const ENV_VALUE_IDS = 'valueIds';
 const GMVALUES_RE = /^GM[_.](listValues|([gs]et|delete)Value)$/;
 const RUN_AT_RE = /^document-(start|body|end|idle)$/;
@@ -279,9 +280,9 @@ export async function getScriptsByURL(url, isTop) {
   const [envStart, envDelayed] = [0, 1].map(() => ({
     ids: [],
     /** @type {(VMScript & VMInjectedScript)[]} */
-    scripts: [],
     [ENV_CACHE_KEYS]: [],
     [ENV_REQ_KEYS]: [],
+    [ENV_SCRIPTS]: [],
     [ENV_VALUE_IDS]: [],
   }));
   allScripts.forEach((script) => {
@@ -310,7 +311,7 @@ export async function getScriptsByURL(url, isTop) {
       });
     }
     /** @namespace VMInjectedScript */
-    env.scripts.push({ ...script, runAt });
+    env[ENV_SCRIPTS].push({ ...script, runAt });
   });
   if (envDelayed.ids.length) {
     envDelayed.promise = readEnvironmentData(envDelayed);

+ 19 - 13
src/background/utils/preinject.js

@@ -1,13 +1,13 @@
 import { getScriptName, getUniqId } from '#/common';
 import {
-  INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING,
+  INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
   INJECTABLE_TAB_URL_RE, METABLOCK_RE,
 } from '#/common/consts';
 import initCache from '#/common/cache';
 import { forEachEntry, objectPick, objectSet } from '#/common/object';
 import storage from '#/common/storage';
 import ua from '#/common/ua';
-import { getScriptsByURL, ENV_CACHE_KEYS, ENV_REQ_KEYS, ENV_VALUE_IDS } from './db';
+import { getScriptsByURL, ENV_CACHE_KEYS, ENV_REQ_KEYS, ENV_SCRIPTS, ENV_VALUE_IDS } from './db';
 import { extensionRoot, postInitialize } from './init';
 import { commands } from './message';
 import { getOption, hookOptions } from './options';
@@ -28,6 +28,7 @@ const cache = initCache({
     rcs?.unregister();
   },
 });
+const FORCE_CONTENT = 'forceContent';
 const INJECT_INTO = 'injectInto';
 // KEY_XXX for hooked options
 const KEY_EXPOSE = 'expose';
@@ -46,7 +47,7 @@ postInitialize.push(() => {
 });
 
 Object.assign(commands, {
-  async InjectionFeedback({ feedId, feedback, forceContent }, src) {
+  async InjectionFeedback({ feedId, feedback, [FORCE_CONTENT]: forceContent }, src) {
     feedback.forEach(processFeedback, src);
     if (feedId) {
       // cache cleanup when getDataFF outruns GetInjected
@@ -54,9 +55,9 @@ Object.assign(commands, {
       // envDelayed
       const env = await cache.pop(feedId.envKey);
       if (env) {
-        env.forceContent = forceContent;
-        env.scripts.map(prepareScript, env).filter(Boolean).forEach(processFeedback, src);
-        return objectPick(env, ['cache', 'scripts']);
+        env[FORCE_CONTENT] = forceContent;
+        env[ENV_SCRIPTS].map(prepareScript, env).filter(Boolean).forEach(processFeedback, src);
+        return objectPick(env, ['cache', ENV_SCRIPTS]);
       }
     }
   },
@@ -225,7 +226,7 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
   const data = await getScriptsByURL(url, !frameId);
   const { envDelayed, scripts } = data;
   const isLate = forceContent != null;
-  data.forceContent = forceContent;
+  data[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
   const feedback = scripts.map(prepareScript, data).filter(Boolean);
   const more = envDelayed.promise;
   const envKey = getUniqId(`${tabId}:${frameId}:`);
@@ -234,16 +235,16 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
   Object.assign(inject, {
     scripts,
     [INJECT_INTO]: injectInto,
+    [INJECT_PAGE]: !forceContent && (
+      scripts.some(isPageRealm, data)
+      || envDelayed[ENV_SCRIPTS].some(isPageRealm, data)
+    ),
     cache: data.cache,
     feedId: {
       cacheKey, // InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected
       envKey, // InjectionFeedback cache key for envDelayed
     },
     hasMore: !!more, // tells content bridge to expect envDelayed
-    forceContent: forceContent || ( // Trying to skip page sandbox when xhrInject is on:
-      feedback.length === scripts.length // ...when all `envStart` scripts are `content`,
-      && envDelayed.scripts.every(scr => isContentRealm(scr, forceContent)) // and `envDelayed` too.
-    ),
     ids: data.disabledIds, // content bridge adds the actually running ids and sends via SetPopup
     info: {
       ua,
@@ -266,7 +267,7 @@ async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent)
 function prepareScript(script) {
   const { custom, meta, props } = script;
   const { id } = props;
-  const { forceContent, require, value } = this;
+  const { [FORCE_CONTENT]: forceContent, require, value } = this;
   const code = this.code[id];
   const dataKey = getUniqId('VMin');
   const displayName = getScriptName(script);
@@ -352,7 +353,7 @@ function detectStrictCsp(responseHeaders) {
 function forceContentInjection(data) {
   /** @type VMGetInjectedData */
   const inject = data.inject;
-  inject.forceContent = true;
+  inject[FORCE_CONTENT] = true;
   inject.scripts.forEach(scr => {
     // When script wants `page`, the result below will be `true` so the script goes into `failedIds`
     scr.code = !isContentRealm(scr, true) || '';
@@ -366,3 +367,8 @@ function isContentRealm(scr, forceContent) {
   );
   return realm === INJECT_CONTENT || forceContent && realm === INJECT_AUTO;
 }
+
+/** @this {VMScriptByUrlData} */
+function isPageRealm(scr) {
+  return !isContentRealm(scr, this[FORCE_CONTENT]);
+}

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

@@ -18,16 +18,17 @@ let bfCacheWired;
 async function init() {
   const contentId = safeGetUniqId();
   const webId = safeGetUniqId();
+  const isXml = document instanceof XMLDocument;
   const xhrData = getXhrInjection();
-  const pageInfo = !xhrData?.forceContent && {
+  const dataPromise = !xhrData && sendCmd('GetInjected', {
     /* 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,
     // XML document's appearance breaks when script elements are added
-    forceContent: document instanceof XMLDocument
-      || !injectPageSandbox(contentId, webId),
-  };
-  const dataPromise = !xhrData && sendCmd('GetInjected', pageInfo, { retry: true });
+    forceContent: isXml,
+  }, {
+    retry: true,
+  });
   // detecting if browser.contentScripts is usable, it was added in FF59 as well as composedPath
   const data = xhrData || (
     IS_FIREFOX && Event[PROTO].composedPath
@@ -35,12 +36,11 @@ async function init() {
       : await dataPromise
   );
   const { allowCmd } = bridge;
-  allowCmd('VaultId', contentId);
   bridge::pickIntoThis(data, [
     'ids',
     'injectInto',
   ]);
-  if (data.expose) {
+  if (data.expose && !isXml && injectPageSandbox(contentId, webId)) {
     allowCmd('GetScriptVer', contentId);
     bridge.addHandlers({ GetScriptVer: true }, true);
     bridge.post('Expose');
@@ -49,7 +49,7 @@ async function init() {
     bridge.onScripts.forEach(fn => fn(data));
     allowCmd('SetTimeout', contentId);
     if (IS_FIREFOX) allowCmd('InjectList', contentId);
-    await injectScripts(contentId, webId, data);
+    await injectScripts(contentId, webId, data, isXml);
   }
   bridge.onScripts = null;
   sendSetPopup();
@@ -121,7 +121,7 @@ async function getDataFF(viaMessaging) {
 
 function getXhrInjection() {
   try {
-    const quotedKey = `"${process.env.INIT_FUNC_NAME}"`;
+    const quotedKey = `"${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];

+ 14 - 9
src/injected/content/inject.js

@@ -9,14 +9,13 @@ import {
  * so we'll use the extension's UUID, which is unique per computer in FF, for messages
  * 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 VAULT_WRITER = `${IS_FIREFOX ? VM_UUID : INIT_FUNC_NAME}VW`;
 const VAULT_WRITER_ACK = `${VAULT_WRITER}+`;
 let contLists;
 let pgLists;
 /** @type {Object<string,VMInjectionRealm>} */
 let realms;
-/** @type boolean */
+/** @type {?boolean} */
 let pageInjectable;
 let frameEventWnd;
 /** @type ShadowRoot */
@@ -60,6 +59,7 @@ bridge.addHandlers({
 });
 
 export function injectPageSandbox(contentId, webId) {
+  pageInjectable = false;
   const { cloneInto } = global;
   const vaultId = safeGetUniqId();
   const handshakeId = safeGetUniqId();
@@ -71,7 +71,7 @@ export function injectPageSandbox(contentId, webId) {
      * Content scripts will see `document.opener = null`, not the original opener, so we have
      * to use an iframe to extract the safe globals. Detection via document.referrer won't work
      * is it can be emptied by the opener page, too. */
-    inject({ code: `parent["${vaultId}"] = [this]` }, ok => {
+    inject({ code: `parent["${vaultId}"] = [this, window]` }, ok => {
       // Skipping page injection in FF if our script element was blocked by site's CSP
       if (ok && (!IS_FIREFOX || window.wrappedJSObject[vaultId])) {
         startHandshake();
@@ -125,31 +125,37 @@ export function injectPageSandbox(contentId, webId) {
  * @param {string} contentId
  * @param {string} webId
  * @param {VMGetInjectedData} data
+ * @param {boolean} isXml
  */
-export async function injectScripts(contentId, webId, data) {
+export async function injectScripts(contentId, webId, data, isXml) {
   const { hasMore, info } = data;
   realms = {
     __proto__: null,
     /** @namespace VMInjectionRealm */
     [INJECT_CONTENT]: {
-      injectable: true,
       /** @namespace VMRunAtLists */
       lists: contLists = { start: [], body: [], end: [], idle: [] },
       is: 0,
       info,
     },
     [INJECT_PAGE]: {
-      injectable: pageInjectable,
       lists: pgLists = { start: [], body: [], end: [], idle: [] },
       is: 0,
       info,
     },
   };
   assign(bridge.cache, data.cache);
+  if (isXml) {
+    pageInjectable = false;
+  }
+  if (data[INJECT_PAGE] && pageInjectable == null) {
+    injectPageSandbox(contentId, webId);
+  }
   const feedback = data.scripts.map((script) => {
     const { id } = script.props;
-    // eslint-disable-next-line no-restricted-syntax
-    const realm = INJECT_MAPPING[script.injectInto].find(key => realms[key]?.injectable);
+    const realm = INJECT_MAPPING[script.injectInto].find(key => (
+      key === INJECT_CONTENT || pageInjectable
+    ));
     // If the script wants this specific realm, which is unavailable, we won't inject it at all
     if (realm) {
       const { pathMap } = script.custom;
@@ -168,7 +174,6 @@ export async function injectScripts(contentId, webId, data) {
     feedId: data.feedId,
     forceContent: !pageInjectable,
   });
-  // saving while safe
   const hasInvoker = realms[INJECT_CONTENT].is;
   if (hasInvoker) {
     setupContentInvoker(contentId, webId);

+ 9 - 3
src/injected/web/gm-global-wrapper.js

@@ -9,11 +9,16 @@ const scopeSym = SafeSymbol.unscopables;
 const globalKeysSet = FastLookup();
 const globalKeys = (function makeGlobalKeys() {
   const kWrappedJSObject = 'wrappedJSObject';
-  const names = getOwnPropertyNames(window);
+  const isContentMode = !process.env.HANDSHAKE_ID;
+  const names = builtinGlobals[0]; // `window` keys
   // True if `names` is usable as is, but FF is bugged: its names have duplicates
   let ok = !IS_FIREFOX;
   names::forEach(key => {
-    if (isFrameIndex(key)) {
+    if (isFrameIndex(key)
+      || isContentMode && (
+        key === process.env.INIT_FUNC_NAME || key === 'browser' || key === 'chrome'
+      )
+    ) {
       ok = false;
     } else {
       globalKeysSet.add(key);
@@ -22,7 +27,7 @@ const globalKeys = (function makeGlobalKeys() {
   /* 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) {
-    getOwnPropertyNames(global)::forEach(key => {
+    builtinGlobals[1]::forEach(key => {
       if (!isFrameIndex(key)) {
         globalKeysSet.add(key);
         ok = false;
@@ -153,6 +158,7 @@ for (const name in unforgeables) { /* proto is null */// eslint-disable-line gua
     inheritedKeys[key] = 1;
   });
 });
+builtinGlobals = null; // eslint-disable-line no-global-assign
 
 /**
  * @desc Wrap helpers to prevent unexpected modifications.

+ 9 - 0
src/injected/web/safe-globals-web.js

@@ -53,6 +53,8 @@ export let
   slice,
   // safeCall
   safeCall,
+  // various values
+  builtinGlobals,
   // various methods
   createObjectURL,
   funcToString,
@@ -85,6 +87,7 @@ export const VAULT = (() => {
   let call;
   let res;
   let src = global; // FF defines some stuff only on `global` in content mode
+  let srcWindow = window;
   if (process.env.VAULT_ID) {
     res = window[process.env.VAULT_ID];
     delete window[process.env.VAULT_ID];
@@ -92,7 +95,9 @@ export const VAULT = (() => {
   if (!res) {
     res = createNullObj();
   } else if (!isFunction(res[0])) {
+    // injectPageSandbox iframe's `this` and `window`
     src = res[0];
+    srcWindow = res[1];
     res = createNullObj();
   }
   res = [
@@ -159,5 +164,9 @@ export const VAULT = (() => {
   ];
   // Well-known Symbols are unforgeable
   toStringTagSym = SafeSymbol.toStringTag;
+  builtinGlobals = [
+    getOwnPropertyNames(srcWindow),
+    src !== srcWindow && getOwnPropertyNames(src),
+  ];
   return res;
 })();