preinject.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import { getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd, trueJoin } from '@/common';
  2. import {
  3. INJECT_AUTO, INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE,
  4. METABLOCK_RE,
  5. } from '@/common/consts';
  6. import initCache from '@/common/cache';
  7. import { forEachEntry, objectPick, objectSet } from '@/common/object';
  8. import ua from '@/common/ua';
  9. import { getScriptsByURL, ENV_CACHE_KEYS, ENV_REQ_KEYS, ENV_SCRIPTS, ENV_VALUE_IDS } from './db';
  10. import { postInitialize } from './init';
  11. import { commands } from './message';
  12. import { getOption, hookOptions } from './options';
  13. import { popupTabs } from './popup-tracker';
  14. import { clearRequestsByTabId } from './requests';
  15. import storage from './storage';
  16. import { clearStorageCache, onStorageChanged } from './storage-cache';
  17. import { addValueOpener, clearValueOpener } from './values';
  18. const API_CONFIG = {
  19. urls: ['*://*/*'], // `*` scheme matches only http and https
  20. types: ['main_frame', 'sub_frame'],
  21. };
  22. const CSAPI_REG = 'csar';
  23. const contentScriptsAPI = browser.contentScripts;
  24. /** In normal circumstances the data will be removed in ~1sec on use,
  25. * however connecting may take a long time or the tab may be paused in devtools. */
  26. const TIME_KEEP_DATA = 5 * 60e3;
  27. const cache = initCache({
  28. lifetime: TIME_KEEP_DATA,
  29. onDispose: contentScriptsAPI && (async val => {
  30. if (val) {
  31. const reg = (val.then ? await val : val)[CSAPI_REG];
  32. if (reg) (await reg).unregister();
  33. }
  34. }),
  35. });
  36. const FEEDBACK = 'feedback';
  37. const HEADERS = 'headers';
  38. const INJECT = 'inject';
  39. const FORCE_CONTENT = 'forceContent';
  40. const INJECT_INTO = 'injectInto';
  41. // KEY_XXX for hooked options
  42. const KEY_EXPOSE = 'expose';
  43. const KEY_DEF_INJECT_INTO = 'defaultInjectInto';
  44. const KEY_IS_APPLIED = 'isApplied';
  45. const KEY_XHR_INJECT = 'xhrInject';
  46. const GRANT_NONE_VARS = '{GM,GM_info,unsafeWindow,cloneInto,createObjectIn,exportFunction}';
  47. const expose = {};
  48. let isApplied;
  49. let injectInto;
  50. let xhrInject;
  51. Object.assign(commands, {
  52. /** @return {Promise<VMInjection>} */
  53. async GetInjected({ url, forceContent }, src) {
  54. const { frameId, tab } = src;
  55. const tabId = tab.id;
  56. if (!url) url = src.url || tab.url;
  57. clearFrameData(tabId, frameId);
  58. const key = getKey(url, !frameId);
  59. const cacheVal = cache.pop(key) || prepare(key, url, tabId, frameId, forceContent);
  60. const bag = cacheVal[INJECT] ? cacheVal : await cacheVal;
  61. /** @type {VMInjection} */
  62. const inject = bag[INJECT];
  63. const feedback = bag[FEEDBACK];
  64. if (feedback?.length) {
  65. // Injecting known content scripts without waiting for InjectionFeedback message.
  66. // Running in a separate task because it may take a long time to serialize data.
  67. setTimeout(injectionFeedback, 0, { [FEEDBACK]: feedback }, src);
  68. }
  69. addValueOpener(tabId, frameId, inject[ENV_SCRIPTS]);
  70. inject.isPopupShown = popupTabs[tabId];
  71. return inject;
  72. },
  73. InjectionFeedback: injectionFeedback,
  74. });
  75. hookOptions(onOptionChanged);
  76. postInitialize.push(() => {
  77. for (const key of [KEY_EXPOSE, KEY_DEF_INJECT_INTO, KEY_IS_APPLIED, KEY_XHR_INJECT]) {
  78. onOptionChanged({ [key]: getOption(key) });
  79. }
  80. });
  81. async function injectionFeedback({
  82. feedId,
  83. [FEEDBACK]: feedback,
  84. [FORCE_CONTENT]: forceContent,
  85. }, src) {
  86. feedback.forEach(processFeedback, src);
  87. if (feedId) {
  88. // cache cleanup when getDataFF outruns GetInjected
  89. cache.del(feedId.cacheKey);
  90. // envDelayed
  91. const env = await cache.pop(feedId.envKey);
  92. if (env) {
  93. env[FORCE_CONTENT] = forceContent;
  94. env[ENV_SCRIPTS].map(prepareScript, env).filter(Boolean).forEach(processFeedback, src);
  95. addValueOpener(src.tab.id, src.frameId, env[ENV_SCRIPTS]);
  96. return objectPick(env, ['cache', ENV_SCRIPTS]);
  97. }
  98. }
  99. }
  100. /** @this {chrome.runtime.MessageSender} */
  101. async function processFeedback([key, runAt, unwrappedId]) {
  102. const code = cache.pop(key);
  103. // see TIME_KEEP_DATA comment
  104. if (runAt && code) {
  105. const { frameId, tab: { id: tabId } } = this;
  106. runAt = `document_${runAt === 'body' ? 'start' : runAt}`;
  107. browser.tabs.executeScript(tabId, { code, frameId, runAt });
  108. if (unwrappedId) sendTabCmd(tabId, 'Run', unwrappedId, { frameId });
  109. }
  110. }
  111. const propsToClear = {
  112. [storage.cache.prefix]: ENV_CACHE_KEYS,
  113. [storage.code.prefix]: true,
  114. [storage.require.prefix]: ENV_REQ_KEYS,
  115. [storage.script.prefix]: true,
  116. [storage.value.prefix]: ENV_VALUE_IDS,
  117. };
  118. onStorageChanged(async ({ keys: dbKeys }) => {
  119. const raw = cache.getValues();
  120. const resolved = !raw.some(val => val?.then);
  121. const cacheValues = resolved ? raw : await Promise.all(raw);
  122. const dirty = cacheValues.some(bag => bag[INJECT]
  123. && dbKeys.some((key) => {
  124. const prefix = key.slice(0, key.indexOf(':') + 1);
  125. const prop = propsToClear[prefix];
  126. key = key.slice(prefix.length);
  127. return prop === true
  128. || bag[prop]?.includes(prefix === storage.value.prefix ? +key : key);
  129. }));
  130. if (dirty) {
  131. cache.destroy();
  132. }
  133. });
  134. function normalizeRealm(value) {
  135. return INJECT_MAPPING::hasOwnProperty(value)
  136. ? value
  137. : injectInto || INJECT_AUTO;
  138. }
  139. function onOptionChanged(changes) {
  140. changes::forEachEntry(([key, value]) => {
  141. switch (key) {
  142. case KEY_DEF_INJECT_INTO:
  143. injectInto = normalizeRealm(value);
  144. cache.destroy();
  145. break;
  146. case KEY_XHR_INJECT:
  147. toggleXhrInject(value);
  148. cache.destroy();
  149. break;
  150. case KEY_IS_APPLIED:
  151. togglePreinject(value);
  152. break;
  153. case KEY_EXPOSE:
  154. value::forEachEntry(([site, isExposed]) => {
  155. expose[decodeURIComponent(site)] = isExposed;
  156. });
  157. break;
  158. default:
  159. if (key.includes('.')) { // used by `expose.url`
  160. onOptionChanged(objectSet({}, key, value));
  161. }
  162. }
  163. });
  164. }
  165. function getKey(url, isTop) {
  166. return isTop ? url : `-${url}`;
  167. }
  168. function togglePreinject(enable) {
  169. isApplied = enable;
  170. // Using onSendHeaders because onHeadersReceived in Firefox fires *after* content scripts.
  171. // And even in Chrome a site may be so fast that preinject on onHeadersReceived won't be useful.
  172. const onOff = `${enable ? 'add' : 'remove'}Listener`;
  173. const config = enable ? API_CONFIG : undefined;
  174. browser.webRequest.onSendHeaders[onOff](onSendHeaders, config);
  175. if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
  176. browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
  177. }
  178. browser.tabs.onRemoved[onOff](onTabRemoved);
  179. browser.tabs.onReplaced[onOff](onTabReplaced);
  180. if (!enable) {
  181. cache.destroy();
  182. clearFrameData();
  183. clearStorageCache();
  184. }
  185. }
  186. function toggleXhrInject(enable) {
  187. xhrInject = enable;
  188. browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived);
  189. if (enable) {
  190. browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, API_CONFIG, [
  191. 'blocking',
  192. 'responseHeaders',
  193. browser.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
  194. ].filter(Boolean));
  195. }
  196. }
  197. function onSendHeaders({ url, tabId, frameId }) {
  198. const isTop = !frameId;
  199. const key = getKey(url, isTop);
  200. if (!cache.has(key)) {
  201. // GetInjected message will be sent soon by the content script
  202. // and it may easily happen while getScriptsByURL is still waiting for browser.storage
  203. // so we'll let GetInjected await this pending data by storing Promise in the cache
  204. cache.put(key, prepare(key, url, tabId, frameId), TIME_KEEP_DATA);
  205. }
  206. }
  207. /** @param {chrome.webRequest.WebResponseHeadersDetails} info */
  208. function onHeadersReceived(info) {
  209. const key = getKey(info.url, !info.frameId);
  210. const bag = xhrInject && cache.get(key);
  211. // Proceeding only if prepareScripts has replaced promise in cache with the actual data
  212. return bag?.[INJECT] && prepareXhrBlob(info, bag);
  213. }
  214. /**
  215. * @param {chrome.webRequest.WebResponseHeadersDetails} info
  216. * @param {VMInjection.Bag} bag
  217. */
  218. function prepareXhrBlob({ url, responseHeaders }, bag) {
  219. if (url.startsWith('https:') && detectStrictCsp(responseHeaders)) {
  220. forceContentInjection(bag);
  221. }
  222. const blobUrl = URL.createObjectURL(new Blob([
  223. JSON.stringify(bag[INJECT]),
  224. ]));
  225. responseHeaders.push({
  226. name: 'Set-Cookie',
  227. value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
  228. });
  229. setTimeout(URL.revokeObjectURL, TIME_KEEP_DATA, blobUrl);
  230. bag[HEADERS] = true;
  231. return { responseHeaders };
  232. }
  233. function prepare(key, url, tabId, frameId, forceContent) {
  234. /** @type {VMInjection.Bag} */
  235. const res = {
  236. [INJECT]: {
  237. expose: !frameId
  238. && url.startsWith('https://')
  239. && expose[url.split('/', 3)[2]],
  240. },
  241. };
  242. return isApplied
  243. ? prepareScripts(res, key, url, tabId, frameId, forceContent)
  244. : res;
  245. }
  246. /**
  247. * @param {VMInjection.Bag} res
  248. * @param cacheKey
  249. * @param url
  250. * @param tabId
  251. * @param frameId
  252. * @param forceContent
  253. * @return {Promise<any>}
  254. */
  255. async function prepareScripts(res, cacheKey, url, tabId, frameId, forceContent) {
  256. const errors = [];
  257. const bag = await getScriptsByURL(url, !frameId, errors);
  258. const { envDelayed, disabledIds: ids, [ENV_SCRIPTS]: scripts } = bag;
  259. const isLate = forceContent != null;
  260. bag[FORCE_CONTENT] = forceContent; // used in prepareScript and isPageRealm
  261. const feedback = scripts.map(prepareScript, bag).filter(Boolean);
  262. const more = envDelayed.promise;
  263. const envKey = getUniqId(`${tabId}:${frameId}:`);
  264. /** @type {VMInjection} */
  265. const inject = res[INJECT];
  266. Object.assign(inject, {
  267. [ENV_SCRIPTS]: scripts,
  268. [INJECT_INTO]: injectInto,
  269. [INJECT_PAGE]: !forceContent && (
  270. scripts.some(isPageRealm, bag)
  271. || envDelayed[ENV_SCRIPTS].some(isPageRealm, bag)
  272. ),
  273. cache: bag.cache,
  274. feedId: {
  275. cacheKey, // InjectionFeedback cache key for cleanup when getDataFF outruns GetInjected
  276. envKey, // InjectionFeedback cache key for envDelayed
  277. },
  278. hasMore: !!more, // tells content bridge to expect envDelayed
  279. ids, // content bridge adds the actually running ids and sends via SetPopup
  280. info: {
  281. ua,
  282. },
  283. errors: errors.filter(err => !ids.includes(+err.slice(err.lastIndexOf('#') + 1))).join('\n'),
  284. });
  285. res[FEEDBACK] = feedback;
  286. res[CSAPI_REG] = contentScriptsAPI && !isLate && !xhrInject
  287. && registerScriptDataFF(inject, url, !!frameId);
  288. if (more) cache.put(envKey, more);
  289. if (!isLate && !cache.get(cacheKey)?.headers) {
  290. cache.put(cacheKey, res); // synchronous onHeadersReceived needs plain object not a Promise
  291. }
  292. return res;
  293. }
  294. /** @this {VMInjection.Env} */
  295. function prepareScript(script) {
  296. const { custom, meta, props } = script;
  297. const { id } = props;
  298. const { [FORCE_CONTENT]: forceContent, require, value } = this;
  299. const code = this.code[id];
  300. const dataKey = getUniqId('VMin');
  301. const displayName = getScriptName(script);
  302. const isContent = isContentRealm(script, forceContent);
  303. const pathMap = custom.pathMap || {};
  304. const reqs = meta.require.map(key => require[pathMap[key] || key]).filter(Boolean);
  305. // trying to avoid progressive string concatenation of potentially huge code slices
  306. // adding `;` on a new line in case some required script ends with a line comment
  307. const reqsSlices = reqs ? [].concat(...reqs.map(req => [req, '\n;'])) : [];
  308. const hasReqs = reqsSlices.length;
  309. const wrap = !meta.unwrap;
  310. const { grant } = meta;
  311. const numGrants = grant.length;
  312. const grantNone = !numGrants || numGrants === 1 && grant[0] === 'none';
  313. const injectedCode = [
  314. wrap && `window.${dataKey}=function(${
  315. // using a shadowed name to avoid scope pollution
  316. grantNone ? GRANT_NONE_VARS : 'GM'}${
  317. IS_FIREFOX ? `,${dataKey}){try{` : '){'}${
  318. grantNone ? '' : 'with(this)with(c)delete c,'
  319. // hiding module interface from @require'd scripts so they don't mistakenly use it
  320. }((define,module,exports)=>{`,
  321. ...reqsSlices,
  322. // adding a nested IIFE to support 'use strict' in the code when there are @requires
  323. hasReqs && wrap && '(()=>{',
  324. code,
  325. // adding a new line in case the code ends with a line comment
  326. !code.endsWith('\n') && '\n',
  327. hasReqs && wrap && '})()',
  328. wrap && `})()${IS_FIREFOX ? `}catch(e){${dataKey}(e)}` : ''}}`,
  329. // 0 at the end to suppress errors about non-cloneable result of executeScript in FF
  330. IS_FIREFOX && ';0',
  331. `\n//# sourceURL=${getScriptPrettyUrl(script, displayName)}`,
  332. ]::trueJoin('');
  333. cache.put(dataKey, injectedCode, TIME_KEEP_DATA);
  334. /** @type {VMInjection.Script} */
  335. Object.assign(script, {
  336. dataKey,
  337. displayName,
  338. // code will be `true` if the desired realm is PAGE which is not injectable
  339. code: isContent ? '' : forceContent || injectedCode,
  340. metaStr: code.match(METABLOCK_RE)[1] || '',
  341. values: value[id] || null,
  342. });
  343. return isContent && [
  344. dataKey,
  345. script.runAt,
  346. !wrap && id, // unwrapped scripts need an explicit `Run` message
  347. ];
  348. }
  349. const resolveDataCodeStr = `(${function _(data) {
  350. /* `function` is required to compile `this`, and `this` is required because our safe-globals
  351. * shadows `window` so its name is minified and hence inaccessible here */
  352. const { vmResolve } = this;
  353. if (vmResolve) {
  354. vmResolve(data);
  355. } else {
  356. // running earlier than the main content script for whatever reason
  357. this.vmData = data;
  358. }
  359. }})`;
  360. // TODO: rework the whole thing to register scripts individually with real `matches`
  361. function registerScriptDataFF(inject, url, allFrames) {
  362. return contentScriptsAPI.register({
  363. allFrames,
  364. js: [{
  365. code: `${resolveDataCodeStr}(${JSON.stringify(inject)})`,
  366. }],
  367. matches: url.split('#', 1),
  368. runAt: 'document_start',
  369. });
  370. }
  371. /** @param {chrome.webRequest.HttpHeader[]} responseHeaders */
  372. function detectStrictCsp(responseHeaders) {
  373. return responseHeaders.some(({ name, value }) => (
  374. /^content-security-policy$/i.test(name)
  375. && /^.(?!.*'unsafe-inline')/.test( // true if not empty and without 'unsafe-inline'
  376. value.match(/(?:^|;)\s*script-src-elem\s[^;]+/)
  377. || value.match(/(?:^|;)\s*script-src\s[^;]+/)
  378. || value.match(/(?:^|;)\s*default-src\s[^;]+/)
  379. || '',
  380. )
  381. ));
  382. }
  383. /** @param {VMInjection.Bag} bag */
  384. function forceContentInjection(bag) {
  385. const inject = bag[INJECT];
  386. inject[FORCE_CONTENT] = true;
  387. inject[ENV_SCRIPTS].forEach(scr => {
  388. // When script wants `page`, the result below will be `true` so the script goes into `failedIds`
  389. scr.code = !isContentRealm(scr, true) || '';
  390. bag[FEEDBACK].push([scr.dataKey, true]);
  391. });
  392. }
  393. function isContentRealm(scr, forceContent) {
  394. const realm = scr[INJECT_INTO] || (
  395. scr[INJECT_INTO] = normalizeRealm(scr.custom[INJECT_INTO] || scr.meta[INJECT_INTO])
  396. );
  397. return realm === INJECT_CONTENT || forceContent && realm === INJECT_AUTO;
  398. }
  399. /** @this {VMInjection.Env} */
  400. function isPageRealm(scr) {
  401. return !isContentRealm(scr, this[FORCE_CONTENT]);
  402. }
  403. function onTabRemoved(id /* , info */) {
  404. clearFrameData(id);
  405. }
  406. function onTabReplaced(addedId, removedId) {
  407. clearFrameData(removedId);
  408. }
  409. function clearFrameData(tabId, frameId) {
  410. clearRequestsByTabId(tabId, frameId);
  411. clearValueOpener(tabId, frameId);
  412. }