inject.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import bridge, { addHandlers } from './bridge';
  2. import { elemByTag, makeElem, nextTask, onElement, sendCmd } from './util';
  3. import { bindEvents, CONSOLE_METHODS, fireBridgeEvent, META_STR } from '../util';
  4. import { Run } from './cmd-run';
  5. const bridgeIds = bridge[IDS];
  6. const kWrappedJSObject = 'wrappedJSObject';
  7. let tardyQueue;
  8. let bridgeInfo;
  9. let contLists;
  10. let pageLists;
  11. /** @type {?boolean} */
  12. let pageInjectable;
  13. let frameEventWnd;
  14. /** @type {ShadowRoot} */
  15. let injectedRoot;
  16. let invokeContent;
  17. let nonce;
  18. let getAttribute;
  19. let querySelector;
  20. // https://bugzil.la/1408996
  21. let VMInitInjection = window[INIT_FUNC_NAME];
  22. /** Avoid running repeatedly due to new `documentElement` or with declarativeContent in Chrome.
  23. * The prop's mode is overridden to be unforgeable by a userscript in content mode. */
  24. setOwnProp(window, INIT_FUNC_NAME, 1, false);
  25. addHandlers({
  26. /**
  27. * FF bug workaround to enable processing of sourceURL in injected page scripts
  28. */
  29. InjectList: IS_FIREFOX && injectPageList,
  30. });
  31. export function injectPageSandbox(data) {
  32. pageInjectable = false;
  33. const VAULT_WRITER = data[kSessionId] + 'VW';
  34. const VAULT_WRITER_ACK = VAULT_WRITER + '*';
  35. const vaultId = safeGetUniqId();
  36. const handshakeId = safeGetUniqId();
  37. const contentId = safeGetUniqId();
  38. const webId = safeGetUniqId();
  39. nonce = data.nonce;
  40. if (IS_FIREFOX) {
  41. // In FF, content scripts running in a same-origin frame cannot directly call parent's functions
  42. window::on(VAULT_WRITER, evt => {
  43. evt::stopImmediatePropagation();
  44. if (!frameEventWnd) {
  45. // setupVaultId's first event is the frame's contentWindow
  46. frameEventWnd = evt::getRelatedTarget();
  47. } else {
  48. // setupVaultId's second event is the vaultId
  49. frameEventWnd::fire(new SafeCustomEvent(VAULT_WRITER_ACK, {
  50. __proto__: null,
  51. detail: tellBridgeToWriteVault(evt::getDetail(), frameEventWnd),
  52. }));
  53. frameEventWnd = null;
  54. }
  55. }, true);
  56. } else {
  57. setOwnProp(global, VAULT_WRITER, tellBridgeToWriteVault, false);
  58. }
  59. if (useOpener(opener) || useOpener(window !== top && parent)) {
  60. startHandshake();
  61. } else {
  62. /* Sites can do window.open(sameOriginUrl,'iframeNameOrNewWindowName').opener=null, spoof JS
  63. * environment and easily hack into our communication channel before our content scripts run.
  64. * Content scripts will see `document.opener = null`, not the original opener, so we have
  65. * to use an iframe to extract the safe globals. Detection via document.referrer won't work
  66. * is it can be emptied by the opener page, too. */
  67. inject({ code: `parent["${vaultId}"] = [this, 0]`/* DANGER! See addVaultExports */ }, () => {
  68. if (!IS_FIREFOX || addVaultExports(window[kWrappedJSObject][vaultId])) {
  69. startHandshake();
  70. }
  71. });
  72. }
  73. return pageInjectable;
  74. function useOpener(opener) {
  75. let ok;
  76. try {
  77. ok = opener && describeProperty(opener.location, 'href').get;
  78. } catch (e) {
  79. // Old Chrome throws in sandboxed frames, TODO: remove `try` when minimum_chrome_version >= 86
  80. }
  81. if (ok) {
  82. ok = false;
  83. // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
  84. if (IS_FIREFOX) {
  85. const setOk = evt => { ok = evt::getDetail(); };
  86. window::on(VAULT_WRITER_ACK, setOk, true);
  87. try {
  88. opener::fire(new SafeMouseEvent(VAULT_WRITER, { relatedTarget: window }));
  89. opener::fire(new SafeCustomEvent(VAULT_WRITER, { detail: vaultId }));
  90. } catch (e) { /* FF quirk or bug: opener may reject our fire */ }
  91. window::off(VAULT_WRITER_ACK, setOk, true);
  92. } else {
  93. ok = opener[VAULT_WRITER];
  94. ok = ok && ok(vaultId, window);
  95. }
  96. }
  97. return ok;
  98. }
  99. /** A page can read our script's textContent in a same-origin iframe via DOMNodeRemoved event.
  100. * Directly preventing it would require redefining ~20 DOM methods in the parent.
  101. * Instead, we'll send the ids via a temporary handshakeId event, to which the web-bridge
  102. * will listen only during its initial phase using vault-protected DOM methods.
  103. * TODO: simplify this when strict_min_version >= 63 (attachShadow in FF) */
  104. function startHandshake() {
  105. /* With `once` the listener is removed before DOMNodeInserted is dispatched by appendChild,
  106. * otherwise a same-origin parent page could use it to spoof the handshake. */
  107. window::on(handshakeId, handshaker, { capture: true, once: true });
  108. inject({
  109. code: `(${VMInitInjection}(${IS_FIREFOX},'${handshakeId}','${vaultId}'))()`
  110. + `\n//# sourceURL=${VM_UUID}sandbox/injected-web.js`,
  111. });
  112. // Clean up in case CSP prevented the script from running
  113. window::off(handshakeId, handshaker, true);
  114. }
  115. function handshaker(evt) {
  116. pageInjectable = true;
  117. evt::stopImmediatePropagation();
  118. bindEvents(contentId, webId, bridge);
  119. fireBridgeEvent(`${handshakeId}*`, [webId, contentId]);
  120. }
  121. }
  122. /**
  123. * @param {VMInjection} data
  124. * @param {VMInjection.Info} info
  125. * @param {boolean} isXml
  126. */
  127. export async function injectScripts(data, info, isXml) {
  128. const { errors, [MORE]: more } = data;
  129. const BODY = 'body';
  130. const CACHE = 'cache';
  131. if (errors) {
  132. logging.warn(errors);
  133. }
  134. info.gmi = {
  135. isIncognito: chrome.extension.inIncognitoContext,
  136. };
  137. bridgeInfo = createNullObj();
  138. bridgeInfo[PAGE] = info;
  139. bridgeInfo[CONTENT] = info;
  140. assign(bridge[CACHE], data[CACHE]);
  141. if (isXml || data[FORCE_CONTENT]) {
  142. pageInjectable = false;
  143. } else if (data[PAGE] && pageInjectable == null) {
  144. injectPageSandbox(data);
  145. }
  146. const toContent = data[SCRIPTS]
  147. .filter(scr => triageScript(scr) === CONTENT)
  148. .map(scr => [scr.id, scr.key.data]);
  149. const moreData = (more || toContent.length)
  150. && sendFeedback(toContent, more);
  151. const getReadyState = more && describeProperty(Document[PROTO], 'readyState').get;
  152. const wasInjectableFF = IS_FIREFOX && !nonce && pageInjectable;
  153. const pageBodyScripts = pageLists?.[BODY];
  154. if (wasInjectableFF) {
  155. getAttribute = Element[PROTO].getAttribute;
  156. querySelector = document.querySelector;
  157. }
  158. tardyQueue = createNullObj();
  159. // Using a callback to avoid a microtask tick when the root element exists or appears.
  160. await onElement('*', injectAll, 'start');
  161. if (pageBodyScripts || contLists?.[BODY]) {
  162. await onElement(BODY, !wasInjectableFF || !pageBodyScripts ? injectAll : arg => {
  163. if (didPageLoseInjectability(toContent, pageBodyScripts)) {
  164. pageLists = null;
  165. contLists ??= createNullObj();
  166. const arr = contLists[BODY];
  167. if (arr) {
  168. for (const scr of pageBodyScripts) safePush(arr, scr);
  169. } else {
  170. contLists[BODY] = pageBodyScripts;
  171. }
  172. sendFeedback(toContent);
  173. }
  174. injectAll(arg);
  175. }, BODY);
  176. }
  177. if (more && (data = await moreData)) {
  178. assign(bridge[CACHE], data[CACHE]);
  179. if (document::getReadyState() === 'loading') {
  180. await new SafePromise(resolve => {
  181. /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
  182. * by listening on `window` which follows `document` when the event bubbles up. */
  183. on('DOMContentLoaded', resolve, { once: true });
  184. });
  185. await 0; // let the site's listeners on `window` run first
  186. }
  187. if (wasInjectableFF && didPageLoseInjectability(toContent, data[SCRIPTS])) {
  188. sendFeedback(toContent);
  189. }
  190. for (const scr of data[SCRIPTS]) {
  191. triageScript(scr);
  192. }
  193. await injectAll('end');
  194. await injectAll('idle');
  195. }
  196. // release for GC
  197. bridgeInfo = contLists = pageLists = VMInitInjection = null;
  198. }
  199. function didPageLoseInjectability(toContent, scripts) {
  200. let res;
  201. if (!toContent) { // self-invoked
  202. pageInjectable = false;
  203. } else if (
  204. !(res = document::querySelector('meta[http-equiv="content-security-policy" i]')) ||
  205. !res::getAttribute('content')
  206. ) {
  207. return; // no CSP element in DOM, [un]reasonably assuming it's not removed
  208. } else {
  209. toContent.length = 0;
  210. }
  211. for (const scr of scripts) {
  212. const realm = scr[INJECT_INTO];
  213. if (realm === PAGE
  214. || realm === AUTO && bridge[INJECT_INTO] !== CONTENT) {
  215. if (toContent) safePush(toContent, [scr.id, scr.key.data]);
  216. else scr[INJECT_INTO] = CONTENT;
  217. }
  218. }
  219. res = toContent?.length;
  220. if (res && pageInjectable) { // may have been cleared when handling pageBodyScriptsFF
  221. const testId = safeGetUniqId();
  222. const obj = window[kWrappedJSObject];
  223. inject({ code: `window["${testId}"]=1` });
  224. res = obj[testId] !== 1;
  225. if (res) didPageLoseInjectability(null, scripts);
  226. else delete obj[testId];
  227. }
  228. return res;
  229. }
  230. function sendFeedback(toContent, more) {
  231. return sendCmd('InjectionFeedback', {
  232. [FORCE_CONTENT]: !pageInjectable,
  233. [CONTENT]: toContent,
  234. [MORE]: more,
  235. url: IS_FIREFOX && location.href,
  236. });
  237. }
  238. function triageScript(script) {
  239. let realm = script[INJECT_INTO];
  240. realm = (realm === AUTO && !pageInjectable) || realm === CONTENT
  241. ? CONTENT
  242. : pageInjectable && PAGE;
  243. if (realm) {
  244. const lists = realm === CONTENT
  245. ? contLists || (contLists = createNullObj())
  246. : pageLists || (pageLists = createNullObj());
  247. const { gmi, [META_STR]: metaStr, pathMap, [RUN_AT]: runAt } = script;
  248. const list = lists[runAt] || (lists[runAt] = []);
  249. safePush(list, script);
  250. setOwnProp(gmi, 'scriptMetaStr', metaStr[0]
  251. || script.code[metaStr[1]]::slice(metaStr[2], metaStr[3]));
  252. delete script[META_STR];
  253. if (pathMap) bridge.pathMaps[script.id] = pathMap;
  254. } else {
  255. bridgeIds[script.id] = ID_BAD_REALM;
  256. }
  257. return realm;
  258. }
  259. function inject(item, iframeCb) {
  260. const { code } = item;
  261. const isCodeArray = isObject(code);
  262. const script = makeElem('script', !isCodeArray && code);
  263. // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
  264. const onError = IS_FIREFOX && !iframeCb && (e => {
  265. const { stack } = e.error;
  266. if (!stack || `${stack}`.includes(VM_UUID)) {
  267. log('error', [item.displayName], e.error);
  268. e.preventDefault();
  269. }
  270. });
  271. const div = makeElem('div');
  272. // Hiding the script's code from mutation events like DOMNodeInserted or DOMNodeRemoved
  273. const divRoot = injectedRoot || (
  274. attachShadow
  275. ? div::attachShadow({ mode: 'closed' })
  276. : div
  277. );
  278. if (isCodeArray) {
  279. safeApply(append, script, code);
  280. }
  281. addNonceAttribute(script);
  282. let iframe;
  283. let iframeDoc;
  284. if (iframeCb) {
  285. iframe = makeElem('iframe', {
  286. /* Preventing other content scripts */// eslint-disable-next-line no-script-url
  287. src: 'javascript:void 0',
  288. sandbox: 'allow-same-origin allow-scripts',
  289. style: 'display:none!important',
  290. });
  291. /* In FF the opener receives DOMNodeInserted attached at creation so it can see window[0] */
  292. if (!IS_FIREFOX) {
  293. divRoot::appendChild(iframe);
  294. }
  295. } else {
  296. divRoot::appendChild(script);
  297. }
  298. if (onError) {
  299. window::on('error', onError);
  300. }
  301. if (!injectedRoot) {
  302. // When using declarativeContent there's no documentElement so we'll append to `document`
  303. (elemByTag('*') || document)::appendChild(div);
  304. }
  305. if (onError) {
  306. window::off('error', onError);
  307. }
  308. if (iframeCb) {
  309. injectedRoot = divRoot;
  310. if (IS_FIREFOX) divRoot::appendChild(iframe);
  311. // Can be removed in DOMNodeInserted by a hostile web page or CSP forbids iframes(?)
  312. if ((iframeDoc = iframe.contentDocument)) {
  313. iframeDoc::getElementsByTagName('*')[0]::appendChild(script);
  314. iframeCb();
  315. }
  316. iframe::remove();
  317. injectedRoot = null;
  318. }
  319. // Clean up in case something didn't load
  320. script::remove();
  321. div::remove();
  322. }
  323. function injectAll(runAt) {
  324. if (contLists && !invokeContent) {
  325. setupContentInvoker();
  326. }
  327. let res;
  328. for (let inPage = 1; inPage >= 0; inPage--) {
  329. const realm = inPage ? PAGE : CONTENT;
  330. const lists = inPage ? pageLists : contLists;
  331. const items = lists?.[runAt];
  332. if (items) {
  333. bridge.post('ScriptData', { items, info: bridgeInfo[realm] }, realm);
  334. bridgeInfo[realm] = false; // must be a sendable value to have own prop in the receiver
  335. for (const { id } of items) tardyQueue[id] = 1;
  336. if (!inPage) nextTask()::then(() => tardyQueueCheck(items));
  337. else if (!IS_FIREFOX) res = injectPageList(runAt);
  338. }
  339. }
  340. return res;
  341. }
  342. async function injectPageList(runAt) {
  343. const scripts = pageLists[runAt];
  344. for (const scr of scripts) {
  345. if (scr.code) {
  346. if (runAt === 'idle') await nextTask();
  347. if (runAt === 'end') await 0;
  348. tardyQueueCheck([scr]);
  349. // Exposing window.vmXXX setter just before running the script to avoid interception
  350. if (!scr.meta.unwrap) bridge.post('Plant', scr.key);
  351. inject(scr);
  352. scr.code = '';
  353. if (scr.meta.unwrap) Run(scr.id);
  354. }
  355. }
  356. }
  357. function setupContentInvoker() {
  358. invokeContent = VMInitInjection(IS_FIREFOX)(bridge.onHandle, logging);
  359. const postViaBridge = bridge.post;
  360. bridge.post = (cmd, params, realm, node) => {
  361. const fn = realm === CONTENT
  362. ? invokeContent
  363. : postViaBridge;
  364. fn(cmd, params, undefined, node);
  365. };
  366. }
  367. /**
  368. * Chrome doesn't fire a syntax error event, so we'll mark ids that didn't start yet
  369. * as "still starting", so the popup can show them accordingly.
  370. */
  371. function tardyQueueCheck(scripts) {
  372. for (const { id } of scripts) {
  373. if (tardyQueue[id]) {
  374. if (bridgeIds[id] === 1) bridgeIds[id] = ID_INJECTING;
  375. delete tardyQueue[id];
  376. }
  377. }
  378. }
  379. function tellBridgeToWriteVault(vaultId, wnd) {
  380. const { post } = bridge;
  381. if (post) { // may be absent if this page doesn't have scripts
  382. post('WriteVault', vaultId, PAGE, wnd);
  383. return true;
  384. }
  385. }
  386. export function addNonceAttribute(script) {
  387. if (nonce) script::setAttribute('nonce', nonce);
  388. }
  389. function addVaultExports(vaultSrc) {
  390. if (!vaultSrc) return; // blocked by CSP
  391. const exports = cloneInto(createNullObj(), document);
  392. // In FF a detached iframe's `console` doesn't print anything, we'll export it from content
  393. const exportedConsole = cloneInto(createNullObj(), document);
  394. CONSOLE_METHODS::forEach(k => {
  395. exportedConsole[k] = exportFunction(logging[k], document);
  396. /* global exportFunction */
  397. });
  398. exports.console = exportedConsole;
  399. // vaultSrc[0] is the iframe's `this`
  400. // DANGER! vaultSrc[1] must be initialized in injectPageSandbox to prevent prototype hooking
  401. vaultSrc[1] = exports;
  402. return true;
  403. }