inject.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import bridge from './bridge';
  2. import { appendToRoot, makeElem, onElement } from './util-content';
  3. import {
  4. bindEvents, fireBridgeEvent,
  5. INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser, sendCmd,
  6. } from '../util';
  7. const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
  8. const VAULT_SEED_NAME = INIT_FUNC_NAME + process.env.VAULT_ID_NAME;
  9. let contLists;
  10. let pgLists;
  11. /** @type {Object<string,VMInjectionRealm>} */
  12. let realms;
  13. /** @type boolean */
  14. let pageInjectable;
  15. let frameEventWnd;
  16. let elShadow;
  17. let elShadowRoot;
  18. // https://bugzil.la/1408996
  19. let VMInitInjection = window[INIT_FUNC_NAME];
  20. /** Avoid running repeatedly due to new `documentElement` or with declarativeContent in Chrome.
  21. * The prop's mode is overridden to be unforgeable by a userscript in content mode. */
  22. defineProperty(window, INIT_FUNC_NAME, {
  23. __proto__: null,
  24. value: 1,
  25. configurable: false,
  26. enumerable: false,
  27. writable: false,
  28. });
  29. window::on(INIT_FUNC_NAME, evt => {
  30. if (!frameEventWnd) {
  31. // setupVaultId's first event is the frame's contentWindow
  32. frameEventWnd = evt::getRelatedTarget();
  33. } else {
  34. // setupVaultId's second event is the vaultId
  35. bridge.post('WriteVault', evt::getDetail(), INJECT_PAGE, frameEventWnd);
  36. frameEventWnd = null;
  37. }
  38. });
  39. bridge.addHandlers({
  40. /**
  41. * FF bug workaround to enable processing of sourceURL in injected page scripts
  42. */
  43. InjectList: IS_FIREFOX && injectList,
  44. /**
  45. * Writes the value into the isolated world of the intercepted window
  46. * @this {Node} window
  47. */
  48. VaultId(id) {
  49. this[VAULT_SEED_NAME] = { id, parent: window };
  50. },
  51. });
  52. export function injectPageSandbox(contentId, webId) {
  53. const { cloneInto } = global;
  54. /* A page can read our script's textContent in a same-origin iframe via DOMNodeRemoved event.
  55. * Directly preventing it would require redefining ~20 DOM methods in the parent.
  56. * Instead, we'll send the ids via a temporary handshakeId event, to which the web-bridge
  57. * will listen only during its initial phase using vault-protected DOM methods. */
  58. const handshakeId = getUniqIdSafe();
  59. const handshaker = () => {
  60. pageInjectable = true;
  61. bindEvents(contentId, webId, bridge, cloneInto);
  62. fireBridgeEvent(handshakeId + process.env.HANDSHAKE_ACK, [webId, contentId], cloneInto);
  63. };
  64. /* The vault contains safe methods that we got from the highest same-origin parent,
  65. * where our code ran at document_start so it definitely predated the page scripts. */
  66. let vaultId = window[VAULT_SEED_NAME];
  67. if (vaultId) {
  68. delete window[VAULT_SEED_NAME];
  69. vaultId = tellParentToWriteVault(vaultId.parent, vaultId.id);
  70. } else {
  71. vaultId = !IS_TOP
  72. && isSameOriginWindow(window.parent)
  73. && tellParentToWriteVault(window.parent, getUniqIdSafe())
  74. || '';
  75. }
  76. /* With `once` the listener is removed before DOMNodeInserted is dispatched by appendChild,
  77. * otherwise a same-origin parent page could use it to spoof the handshake. */
  78. window::on(handshakeId, handshaker, { capture: true, once: true });
  79. inject({
  80. code: `(${VMInitInjection}(${IS_FIREFOX},'${handshakeId}','${vaultId}'))()`
  81. + `\n//# sourceURL=${browser.runtime.getURL('sandbox/injected-web.js')}`,
  82. });
  83. // Clean up in case CSP prevented the script from running
  84. window::off(handshakeId, handshaker, true);
  85. }
  86. /**
  87. * @param {string} contentId
  88. * @param {string} webId
  89. * @param {VMGetInjectedData} data
  90. */
  91. export async function injectScripts(contentId, webId, data) {
  92. const { hasMore, info } = data;
  93. realms = {
  94. __proto__: null,
  95. /** @namespace VMInjectionRealm */
  96. [INJECT_CONTENT]: {
  97. injectable: true,
  98. /** @namespace VMRunAtLists */
  99. lists: contLists = { start: [], body: [], end: [], idle: [] },
  100. is: 0,
  101. info,
  102. },
  103. [INJECT_PAGE]: {
  104. injectable: pageInjectable,
  105. lists: pgLists = { start: [], body: [], end: [], idle: [] },
  106. is: 0,
  107. info,
  108. },
  109. };
  110. assign(bridge.cache, data.cache);
  111. const feedback = data.scripts.map((script) => {
  112. const { id } = script.props;
  113. // eslint-disable-next-line no-restricted-syntax
  114. const realm = INJECT_MAPPING[script.injectInto].find(key => realms[key]?.injectable);
  115. // If the script wants this specific realm, which is unavailable, we won't inject it at all
  116. if (realm) {
  117. const { pathMap } = script.custom;
  118. const realmData = realms[realm];
  119. realmData.lists[script.runAt].push(script); // 'start' or 'body' per getScriptsByURL()
  120. realmData.is = true;
  121. if (pathMap) bridge.pathMaps[id] = pathMap;
  122. bridge.allowScript(script);
  123. } else {
  124. bridge.failedIds.push(id);
  125. }
  126. return [script.dataKey, realm === INJECT_CONTENT];
  127. });
  128. const moreData = sendCmd('InjectionFeedback', {
  129. feedback,
  130. pageInjectable,
  131. feedId: data.feedId,
  132. });
  133. // saving while safe
  134. const getReadyState = hasMore && describeProperty(Document[PROTO], 'readyState').get;
  135. const hasInvoker = realms[INJECT_CONTENT].is;
  136. if (hasInvoker) {
  137. setupContentInvoker(contentId, webId);
  138. }
  139. // Using a callback to avoid a microtask tick when the root element exists or appears.
  140. await onElement('*', async () => {
  141. injectAll('start');
  142. const onBody = (pgLists.body.length || contLists.body.length)
  143. && onElement('body', injectAll, 'body');
  144. // document-end, -idle
  145. if (hasMore) {
  146. data = await moreData;
  147. if (data) await injectDelayedScripts(!hasInvoker && contentId, webId, data, getReadyState);
  148. }
  149. if (onBody) {
  150. await onBody;
  151. }
  152. realms = null;
  153. pgLists = null;
  154. contLists = null;
  155. });
  156. VMInitInjection = null; // release for GC
  157. }
  158. async function injectDelayedScripts(contentId, webId, { cache, scripts }, getReadyState) {
  159. assign(bridge.cache, cache);
  160. let needsInvoker;
  161. scripts::forEach(script => {
  162. const { code, runAt } = script;
  163. if (code && !pageInjectable) {
  164. bridge.failedIds::push(script.props.id);
  165. } else {
  166. (code ? pgLists : contLists)[runAt]::push(script);
  167. if (!code) needsInvoker = true;
  168. }
  169. script.stage = !code && runAt;
  170. });
  171. if (document::getReadyState() === 'loading') {
  172. await new PromiseSafe(resolve => {
  173. /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
  174. * by listening on `window` which follows `document` when the event bubbles up. */
  175. window::on('DOMContentLoaded', resolve, { once: true });
  176. });
  177. }
  178. if (needsInvoker && contentId) {
  179. setupContentInvoker(contentId, webId);
  180. }
  181. scripts::forEach(bridge.allowScript);
  182. injectAll('end');
  183. injectAll('idle');
  184. }
  185. function inject(item) {
  186. const realScript = makeElem('script', item.code);
  187. let script = realScript;
  188. // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
  189. let onError;
  190. if (IS_FIREFOX) {
  191. onError = e => {
  192. const { stack } = e.error;
  193. if (!stack || `${stack}`.includes(browser.runtime.getURL(''))) {
  194. log('error', [item.displayName], e.error);
  195. e.preventDefault();
  196. }
  197. };
  198. window::on('error', onError);
  199. }
  200. // Hiding the script's code from mutation events like DOMNodeInserted or DOMNodeRemoved
  201. if (attachShadow) {
  202. if (!elShadow) {
  203. elShadow = makeElem('div');
  204. elShadowRoot = elShadow::attachShadow({ mode: 'closed' });
  205. elShadowRoot::appendChild(makeElem('style', ':host { display: none !important }'));
  206. }
  207. elShadowRoot::appendChild(realScript);
  208. script = elShadow;
  209. }
  210. // When using declarativeContent there's no documentElement so we'll append to `document`
  211. if (!appendToRoot(script)) document::appendChild(script);
  212. if (onError) window::off('error', onError);
  213. if (attachShadow) realScript::remove();
  214. script::remove();
  215. }
  216. function injectAll(runAt) {
  217. for (const realm in realms) { /* proto is null */// eslint-disable-line guard-for-in
  218. const realmData = realms[realm];
  219. const items = realmData.lists[runAt];
  220. const { info } = realmData;
  221. if (items.length) {
  222. bridge.post('ScriptData', { info, items, runAt }, realm);
  223. if (realm === INJECT_PAGE && !IS_FIREFOX) {
  224. injectList(runAt);
  225. }
  226. }
  227. }
  228. if (runAt !== 'start' && contLists[runAt].length) {
  229. bridge.post('RunAt', runAt, INJECT_CONTENT);
  230. }
  231. }
  232. async function injectList(runAt) {
  233. const list = pgLists[runAt];
  234. // Not using for-of because we don't know if @@iterator is safe.
  235. for (let i = 0, item; (item = list[i]); i += 1) {
  236. if (item.code) {
  237. if (runAt === 'idle') await sendCmd('SetTimeout', 0);
  238. if (runAt === 'end') await 0;
  239. inject(item);
  240. item.code = '';
  241. }
  242. }
  243. }
  244. function setupContentInvoker(contentId, webId) {
  245. const invokeContent = VMInitInjection(IS_FIREFOX)(webId, contentId, bridge.onHandle);
  246. const postViaBridge = bridge.post;
  247. bridge.post = (cmd, params, realm, node) => {
  248. const fn = realm === INJECT_CONTENT
  249. ? invokeContent
  250. : postViaBridge;
  251. fn(cmd, params, undefined, node);
  252. };
  253. }
  254. function tellParentToWriteVault(parent, vaultId) {
  255. // In FF, content scripts running in a same-origin frame cannot directly call parent's functions
  256. // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
  257. parent::fire(new MouseEventSafe(INIT_FUNC_NAME, { relatedTarget: window }));
  258. parent::fire(new CustomEventSafe(INIT_FUNC_NAME, { detail: vaultId }));
  259. return vaultId;
  260. }