preinject.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd } from '@/common';
  2. import { BLACKLIST, HOMEPAGE_URL, META_STR, METABLOCK_RE, NEWLINE_END_RE } from '@/common/consts';
  3. import initCache from '@/common/cache';
  4. import { forEachEntry, forEachKey, forEachValue, mapEntry, objectSet } from '@/common/object';
  5. import ua from '@/common/ua';
  6. import { getScriptsByURL, CACHE_KEYS, PROMISE, REQ_KEYS, VALUE_IDS } from './db';
  7. import { postInitialize } from './init';
  8. import { addPublicCommands } from './message';
  9. import { getOption, hookOptions } from './options';
  10. import { popupTabs } from './popup-tracker';
  11. import { clearRequestsByTabId } from './requests';
  12. import {
  13. S_CACHE, S_CACHE_PRE, S_CODE, S_CODE_PRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE, S_VALUE_PRE,
  14. } from './storage';
  15. import { clearStorageCache, onStorageChanged } from './storage-cache';
  16. import { addValueOpener, clearValueOpener } from './values';
  17. let isApplied;
  18. let injectInto;
  19. let ffInject;
  20. let xhrInject;
  21. const sessionId = getUniqId();
  22. const API_CONFIG = {
  23. urls: ['*://*/*'], // `*` scheme matches only http and https
  24. types: ['main_frame', 'sub_frame'],
  25. };
  26. const __CODE = Symbol('code'); // will be stripped when messaging
  27. const INJECT = 'inject';
  28. /** These bags are reused in cache to reduce memory usage,
  29. * CACHE_KEYS is for removeStaleCacheEntry */
  30. const BAG_NOOP = { [INJECT]: {}, [CACHE_KEYS]: [] };
  31. const BAG_NOOP_EXPOSE = { ...BAG_NOOP, [INJECT]: { [EXPOSE]: true, [kSessionId]: sessionId } };
  32. const CSAPI_REG = 'csReg';
  33. const contentScriptsAPI = browser.contentScripts;
  34. const cache = initCache({
  35. lifetime: 5 * 60e3,
  36. onDispose(val) {
  37. val[CSAPI_REG]?.then(reg => reg.unregister());
  38. cache.del(val[MORE]);
  39. },
  40. });
  41. // KEY_XXX for hooked options
  42. const GRANT_NONE_VARS = '{GM,GM_info,unsafeWindow,cloneInto,createObjectIn,exportFunction}';
  43. const META_KEYS_TO_ENSURE = [
  44. 'description',
  45. 'name',
  46. 'namespace',
  47. [RUN_AT],
  48. 'version',
  49. ];
  50. const META_KEYS_TO_ENSURE_FROM = [
  51. [HOMEPAGE_URL, 'homepage'],
  52. ];
  53. const META_KEYS_TO_PLURALIZE_RE = /^(?:(m|excludeM)atch|(ex|in)clude)$/;
  54. const pluralizeMetaKey = (s, consonant) => s + (consonant ? 'es' : 's');
  55. const pluralizeMeta = key => key.replace(META_KEYS_TO_PLURALIZE_RE, pluralizeMetaKey);
  56. const UNWRAP = 'unwrap';
  57. const KNOWN_INJECT_INTO = {
  58. [AUTO]: 1,
  59. [CONTENT]: 1,
  60. [PAGE]: 1,
  61. };
  62. const propsToClear = {
  63. [S_CACHE_PRE]: CACHE_KEYS,
  64. [S_CODE_PRE]: true,
  65. [S_REQUIRE_PRE]: REQ_KEYS,
  66. [S_SCRIPT_PRE]: true,
  67. [S_VALUE_PRE]: VALUE_IDS,
  68. };
  69. const expose = {};
  70. const resolveDataCodeStr = `(${(global, data) => {
  71. if (global.vmResolve) global.vmResolve(data); // `window` is a const which is inaccessible here
  72. else global.vmData = data; // Ran earlier than the main content script so just drop the payload
  73. }})`;
  74. const getKey = (url, isTop) => (
  75. isTop ? url : `-${url}`
  76. );
  77. const normalizeRealm = val => (
  78. KNOWN_INJECT_INTO[val] ? val : injectInto || AUTO
  79. );
  80. const normalizeScriptRealm = (custom, meta) => (
  81. normalizeRealm(custom[INJECT_INTO] || meta[INJECT_INTO])
  82. );
  83. const isContentRealm = (val, force) => (
  84. val === CONTENT || val === AUTO && force
  85. );
  86. const OPT_HANDLERS = {
  87. [BLACKLIST]: cache.destroy,
  88. defaultInjectInto(value) {
  89. injectInto = normalizeRealm(value);
  90. cache.destroy();
  91. },
  92. /** WARNING! toggleXhrInject should precede togglePreinject as it sets xhrInject variable */
  93. xhrInject: toggleXhrInject,
  94. isApplied: togglePreinject,
  95. [EXPOSE](value) {
  96. value::forEachEntry(([site, isExposed]) => {
  97. expose[decodeURIComponent(site)] = isExposed;
  98. });
  99. },
  100. };
  101. if (contentScriptsAPI) OPT_HANDLERS.ffInject = toggleFastFirefoxInject;
  102. addPublicCommands({
  103. /** @return {Promise<VMInjection>} */
  104. async GetInjected({ url, [FORCE_CONTENT]: forceContent, done }, src) {
  105. const { frameId, tab } = src;
  106. const tabId = tab.id;
  107. const isTop = !frameId;
  108. if (!url) url = src.url || tab.url;
  109. clearFrameData(tabId, frameId);
  110. const bagKey = getKey(url, isTop);
  111. const bagP = cache.get(bagKey) || prepare(bagKey, url, isTop);
  112. const bag = bagP[INJECT] ? bagP : await bagP[PROMISE];
  113. /** @type {VMInjection} */
  114. const inject = bag[INJECT];
  115. const scripts = inject[SCRIPTS];
  116. if (scripts) {
  117. triageRealms(scripts, bag[FORCE_CONTENT] || forceContent, tabId, frameId, bag);
  118. addValueOpener(scripts, tabId, frameId);
  119. }
  120. if (popupTabs[tabId]) {
  121. setTimeout(sendTabCmd, 0, tabId, 'PopupShown', popupTabs[tabId], { frameId });
  122. }
  123. return !done && inject;
  124. },
  125. async InjectionFeedback({
  126. [FORCE_CONTENT]: forceContent,
  127. [CONTENT]: items,
  128. [MORE]: moreKey,
  129. url,
  130. }, src) {
  131. const { frameId, tab } = src;
  132. const tabId = tab.id;
  133. injectContentRealm(items, tabId, frameId);
  134. if (!moreKey) return;
  135. if (!url) url = src.url || tab.url;
  136. let more = cache.get(moreKey)
  137. || cache.put(moreKey, getScriptsByURL(url, !frameId));
  138. const envCache = more[S_CACHE]
  139. || cache.put(moreKey, more = await more[PROMISE])[S_CACHE];
  140. const scripts = prepareScripts(more);
  141. triageRealms(scripts, forceContent, tabId, frameId);
  142. addValueOpener(scripts, tabId, frameId);
  143. return {
  144. [SCRIPTS]: scripts,
  145. [S_CACHE]: envCache,
  146. };
  147. },
  148. });
  149. hookOptions(onOptionChanged);
  150. postInitialize.push(() => {
  151. OPT_HANDLERS::forEachKey(key => {
  152. onOptionChanged({ [key]: getOption(key) });
  153. });
  154. });
  155. onStorageChanged(({ keys }) => {
  156. cache.some(removeStaleCacheEntry, keys.map((key, i) => [
  157. key.slice(0, i = key.indexOf(':') + 1),
  158. key.slice(i),
  159. ]));
  160. });
  161. /** @this {string[][]} changed storage keys, already split as [prefix,id] */
  162. function removeStaleCacheEntry(val, key) {
  163. if (!val[CACHE_KEYS]) return;
  164. for (const [prefix, id] of this) {
  165. const prop = propsToClear[prefix];
  166. if (prop === true) {
  167. cache.destroy(); // TODO: try to patch the cache in-place?
  168. return true; // stops further processing as the cache is clear now
  169. }
  170. if (val[prop]?.includes(+id || id)) {
  171. if (prefix === S_REQUIRE_PRE) {
  172. val.depsMap[id].forEach(id => cache.del(S_SCRIPT_PRE + id));
  173. } else {
  174. cache.del(key); // TODO: try to patch the cache in-place?
  175. }
  176. }
  177. }
  178. }
  179. function onOptionChanged(changes) {
  180. changes::forEachEntry(([key, value]) => {
  181. if (OPT_HANDLERS[key]) {
  182. OPT_HANDLERS[key](value);
  183. } else if (key.includes('.')) { // used by `expose.url`
  184. onOptionChanged(objectSet({}, key, value));
  185. }
  186. });
  187. }
  188. function togglePreinject(enable) {
  189. isApplied = enable;
  190. // Using onSendHeaders because onHeadersReceived in Firefox fires *after* content scripts.
  191. // And even in Chrome a site may be so fast that preinject on onHeadersReceived won't be useful.
  192. const onOff = `${enable ? 'add' : 'remove'}Listener`;
  193. const config = enable ? API_CONFIG : undefined;
  194. browser.webRequest.onSendHeaders[onOff](onSendHeaders, config);
  195. if (!isApplied || !xhrInject) { // will be registered in toggleXhrInject
  196. browser.webRequest.onHeadersReceived[onOff](onHeadersReceived, config);
  197. }
  198. browser.tabs.onRemoved[onOff](onTabRemoved);
  199. browser.tabs.onReplaced[onOff](onTabReplaced);
  200. if (!enable) {
  201. cache.destroy();
  202. clearFrameData();
  203. clearStorageCache();
  204. }
  205. }
  206. function toggleFastFirefoxInject(enable) {
  207. ffInject = enable;
  208. if (!enable) {
  209. cache.some(val => {
  210. if (val[CSAPI_REG]) {
  211. val[CSAPI_REG].then(reg => reg.unregister());
  212. delete val[CSAPI_REG];
  213. }
  214. });
  215. } else if (!xhrInject) {
  216. cache.destroy(); // nuking the cache so that CSAPI_REG is created for subsequent injections
  217. }
  218. }
  219. function toggleXhrInject(enable) {
  220. xhrInject = enable;
  221. cache.destroy();
  222. browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived);
  223. if (enable) {
  224. browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, API_CONFIG, [
  225. 'blocking',
  226. kResponseHeaders,
  227. browser.webRequest.OnHeadersReceivedOptions.EXTRA_HEADERS,
  228. ].filter(Boolean));
  229. }
  230. }
  231. function onSendHeaders({ url, frameId }) {
  232. const isTop = !frameId;
  233. const key = getKey(url, isTop);
  234. if (!cache.has(key)) prepare(key, url, isTop);
  235. }
  236. /** @param {chrome.webRequest.WebResponseHeadersDetails} info */
  237. function onHeadersReceived(info) {
  238. const key = getKey(info.url, !info.frameId);
  239. const bag = xhrInject && cache.get(key);
  240. // The INJECT data is normally already in cache if code and values aren't huge
  241. return bag?.[INJECT]?.[SCRIPTS] && prepareXhrBlob(info, bag);
  242. }
  243. /**
  244. * @param {chrome.webRequest.WebResponseHeadersDetails} info
  245. * @param {VMInjection.Bag} bag
  246. */
  247. function prepareXhrBlob({ url, [kResponseHeaders]: responseHeaders, tabId, frameId }, bag) {
  248. if (IS_FIREFOX && url.startsWith('https:') && detectStrictCsp(responseHeaders)) {
  249. bag[FORCE_CONTENT] = true;
  250. }
  251. triageRealms(bag[INJECT][SCRIPTS], bag[FORCE_CONTENT], tabId, frameId, bag);
  252. const blobUrl = URL.createObjectURL(new Blob([
  253. JSON.stringify(bag[INJECT]),
  254. ]));
  255. responseHeaders.push({
  256. name: 'Set-Cookie',
  257. value: `"${process.env.INIT_FUNC_NAME}"=${blobUrl.split('/').pop()}; SameSite=Lax`,
  258. });
  259. setTimeout(URL.revokeObjectURL, 60e3, blobUrl);
  260. return { [kResponseHeaders]: responseHeaders };
  261. }
  262. function prepare(cacheKey, url, isTop) {
  263. const shouldExpose = isTop && url.startsWith('https://') && expose[url.split('/', 3)[2]];
  264. const bagNoOp = shouldExpose ? BAG_NOOP_EXPOSE : BAG_NOOP;
  265. if (!isApplied) {
  266. return bagNoOp;
  267. }
  268. const errors = [];
  269. // TODO: teach `getScriptEnv` to skip prepared scripts in cache
  270. const env = getScriptsByURL(url, isTop, errors);
  271. if (env) {
  272. env[PROMISE] = prepareBag(cacheKey, url, isTop,
  273. env, shouldExpose ? { [EXPOSE]: true } : {}, errors);
  274. }
  275. return cache.put(cacheKey, env || bagNoOp);
  276. }
  277. async function prepareBag(cacheKey, url, isTop, env, inject, errors) {
  278. await env[PROMISE];
  279. cache.batch(true);
  280. const bag = { [INJECT]: inject };
  281. const { allIds, [MORE]: envDelayed } = env;
  282. const moreKey = envDelayed[PROMISE] && getUniqId('more');
  283. Object.assign(inject, {
  284. [S_CACHE]: env[S_CACHE],
  285. [SCRIPTS]: prepareScripts(env),
  286. [INJECT_INTO]: injectInto,
  287. [MORE]: moreKey,
  288. [kSessionId]: sessionId,
  289. [IDS]: allIds,
  290. clipFF: env.clipFF,
  291. info: { ua },
  292. errors: errors.filter(err => allIds[err.split('#').pop()]).join('\n'),
  293. });
  294. propsToClear::forEachValue(val => {
  295. if (val !== true) bag[val] = env[val];
  296. });
  297. bag[MORE] = envDelayed;
  298. if (ffInject && contentScriptsAPI && !xhrInject && isTop) {
  299. inject[PAGE] = env[PAGE] || triagePageRealm(envDelayed);
  300. bag[CSAPI_REG] = registerScriptDataFF(inject, url);
  301. }
  302. if (moreKey) {
  303. cache.put(moreKey, envDelayed);
  304. envDelayed[MORE] = cacheKey;
  305. }
  306. cache.put(cacheKey, bag);
  307. cache.batch(false);
  308. return bag;
  309. }
  310. function prepareScripts(env) {
  311. const scripts = env[SCRIPTS];
  312. for (let i = 0, script, key, id; i < scripts.length; i++) {
  313. script = scripts[i];
  314. id = script.id;
  315. if (!script[__CODE]) {
  316. id = script.props.id;
  317. key = S_SCRIPT_PRE + id;
  318. script = cache.get(key) || cache.put(key, prepareScript(script, env));
  319. scripts[i] = script;
  320. }
  321. if (script[INJECT_INTO] !== CONTENT) {
  322. env[PAGE] = true; // for registerScriptDataFF
  323. }
  324. script[VALUES] = env[S_VALUE][id] || null;
  325. }
  326. return scripts;
  327. }
  328. /**
  329. * @param {VMScript} script
  330. * @param {VMInjection.EnvStart} env
  331. * @return {VMInjection.Script}
  332. */
  333. function prepareScript(script, env) {
  334. const { custom, meta, props } = script;
  335. const { id } = props;
  336. const { require, [RUN_AT]: runAt } = env;
  337. const code = env[S_CODE][id];
  338. const dataKey = getUniqId();
  339. const winKey = getUniqId();
  340. const key = { data: dataKey, win: winKey };
  341. const displayName = getScriptName(script);
  342. const pathMap = custom.pathMap || {};
  343. const wrap = !meta[UNWRAP];
  344. const { grant } = meta;
  345. const numGrants = grant.length;
  346. const grantNone = !numGrants || numGrants === 1 && grant[0] === 'none';
  347. // Storing slices separately to reuse JS-internalized strings for code in our storage cache
  348. const injectedCode = [];
  349. const metaCopy = meta::mapEntry(null, pluralizeMeta);
  350. const metaStrMatch = METABLOCK_RE.exec(code);
  351. let hasReqs;
  352. let codeIndex;
  353. let tmp;
  354. for (const key of META_KEYS_TO_ENSURE) {
  355. if (metaCopy[key] == null) metaCopy[key] = '';
  356. }
  357. for (const [key, from] of META_KEYS_TO_ENSURE_FROM) {
  358. if (!metaCopy[key] && (tmp = metaCopy[from])) {
  359. metaCopy[key] = tmp;
  360. }
  361. }
  362. if (wrap) {
  363. // TODO: push winKey/dataKey as separate chunks so we can change them for each injection?
  364. injectedCode.push(`window.${winKey}=function ${dataKey}(`
  365. // using a shadowed name to avoid scope pollution
  366. + (grantNone ? GRANT_NONE_VARS : 'GM')
  367. + (IS_FIREFOX ? `,${dataKey}){try{` : '){')
  368. + (grantNone ? '' : 'with(this)with(c)delete c,')
  369. // hiding module interface from @require'd scripts so they don't mistakenly use it
  370. + '((define,module,exports)=>{');
  371. }
  372. for (const url of meta.require) {
  373. const req = require[pathMap[url] || url];
  374. if (/\S/.test(req)) {
  375. injectedCode.push(req, NEWLINE_END_RE.test(req) ? ';' : '\n;');
  376. hasReqs = true;
  377. }
  378. }
  379. // adding a nested IIFE to support 'use strict' in the code when there are @requires
  380. if (hasReqs && wrap) {
  381. injectedCode.push('(()=>{');
  382. }
  383. codeIndex = injectedCode.length;
  384. injectedCode.push(code);
  385. // adding a new line in case the code ends with a line comment
  386. injectedCode.push((!NEWLINE_END_RE.test(code) ? '\n' : '')
  387. + (hasReqs && wrap ? '})()' : '')
  388. + (wrap ? `})()${IS_FIREFOX ? `}catch(e){${dataKey}(e)}` : ''}}` : '')
  389. // 0 at the end to suppress errors about non-cloneable result of executeScript in FF
  390. + (IS_FIREFOX ? ';0' : '')
  391. + `\n//# sourceURL=${getScriptPrettyUrl(script, displayName)}`);
  392. return {
  393. code: '',
  394. displayName,
  395. gmi: {
  396. scriptWillUpdate: !!script.config.shouldUpdate,
  397. uuid: props.uuid,
  398. },
  399. id,
  400. key,
  401. meta: metaCopy,
  402. pathMap,
  403. [__CODE]: injectedCode,
  404. [INJECT_INTO]: normalizeScriptRealm(custom, meta),
  405. [META_STR]: [
  406. '',
  407. codeIndex,
  408. tmp = (metaStrMatch.index + metaStrMatch[1].length),
  409. tmp + metaStrMatch[2].length,
  410. ],
  411. [RUN_AT]: runAt[id],
  412. };
  413. }
  414. function triageRealms(scripts, forceContent, tabId, frameId, bag) {
  415. let code;
  416. let wantsPage;
  417. const toContent = [];
  418. for (const scr of scripts) {
  419. const metaStr = scr[META_STR];
  420. if (isContentRealm(scr[INJECT_INTO], forceContent)) {
  421. if (!metaStr[0]) {
  422. const [, i, from, to] = metaStr;
  423. metaStr[0] = scr[__CODE][i].slice(from, to);
  424. }
  425. code = '';
  426. toContent.push([scr.id, scr.key.data]);
  427. } else {
  428. metaStr[0] = '';
  429. code = forceContent ? ID_BAD_REALM : scr[__CODE];
  430. if (!forceContent) wantsPage = true;
  431. }
  432. scr.code = code;
  433. }
  434. if (bag) {
  435. bag[INJECT][PAGE] = wantsPage || triagePageRealm(bag[MORE]);
  436. }
  437. if (toContent[0]) {
  438. // Processing known feedback without waiting for InjectionFeedback message.
  439. // Running in a separate task as executeScript may take a long time to serialize code.
  440. setTimeout(injectContentRealm, 0, toContent, tabId, frameId);
  441. }
  442. }
  443. function triagePageRealm(env, forceContent) {
  444. return env?.[SCRIPTS].some(isPageRealmScript, forceContent || null);
  445. }
  446. function injectContentRealm(toContent, tabId, frameId) {
  447. for (const [id, dataKey] of toContent) {
  448. const scr = cache.get(S_SCRIPT_PRE + id); // TODO: recreate if expired?
  449. if (!scr || scr.key.data !== dataKey) continue;
  450. browser.tabs.executeScript(tabId, {
  451. code: scr[__CODE].join(''),
  452. runAt: `document_${scr[RUN_AT]}`.replace('body', 'start'),
  453. frameId,
  454. }).then(scr.meta[UNWRAP] && (() => sendTabCmd(tabId, 'Run', id, { frameId })));
  455. }
  456. }
  457. // TODO: rework the whole thing to register scripts individually with real `matches`
  458. // (this will also allow proper handling of @noframes)
  459. function registerScriptDataFF(inject, url) {
  460. for (const scr of inject[SCRIPTS]) {
  461. scr.code = scr[__CODE];
  462. }
  463. return contentScriptsAPI.register({
  464. js: [{
  465. code: `${resolveDataCodeStr}(this,${JSON.stringify(inject)})`,
  466. }],
  467. matches: url.split('#', 1),
  468. runAt: 'document_start',
  469. });
  470. }
  471. /** @param {chrome.webRequest.HttpHeader[]} responseHeaders */
  472. function detectStrictCsp(responseHeaders) {
  473. return responseHeaders.some(({ name, value }) => (
  474. /^content-security-policy$/i.test(name)
  475. && /^.(?!.*'unsafe-inline')/.test( // true if not empty and without 'unsafe-inline'
  476. value.match(/(?:^|;)\s*script-src-elem\s[^;]+/)
  477. || value.match(/(?:^|;)\s*script-src\s[^;]+/)
  478. || value.match(/(?:^|;)\s*default-src\s[^;]+/)
  479. || '',
  480. )
  481. ));
  482. }
  483. /** @this {?} truthy = forceContent */
  484. function isPageRealmScript(scr) {
  485. return !isContentRealm(scr[INJECT_INTO] || normalizeScriptRealm(scr.custom, scr.meta), this);
  486. }
  487. function onTabRemoved(id /* , info */) {
  488. clearFrameData(id);
  489. }
  490. function onTabReplaced(addedId, removedId) {
  491. clearFrameData(removedId);
  492. }
  493. function clearFrameData(tabId, frameId) {
  494. clearRequestsByTabId(tabId, frameId);
  495. clearValueOpener(tabId, frameId);
  496. }