| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- import bridge from './bridge';
- import { appendToRoot, makeElem, onElement } from './util-content';
- import {
- bindEvents, fireBridgeEvent,
- INJECT_CONTENT, INJECT_MAPPING, INJECT_PAGE, browser, sendCmd,
- } from '../util';
- const INIT_FUNC_NAME = process.env.INIT_FUNC_NAME;
- const VAULT_SEED_NAME = INIT_FUNC_NAME + process.env.VAULT_ID_NAME;
- let contLists;
- let pgLists;
- /** @type {Object<string,VMInjectionRealm>} */
- let realms;
- /** @type boolean */
- let pageInjectable;
- let frameEventWnd;
- let elShadow;
- let elShadowRoot;
- // https://bugzil.la/1408996
- let VMInitInjection = window[INIT_FUNC_NAME];
- /** Avoid running repeatedly due to new `documentElement` or with declarativeContent in Chrome.
- * The prop's mode is overridden to be unforgeable by a userscript in content mode. */
- defineProperty(window, INIT_FUNC_NAME, {
- __proto__: null,
- value: 1,
- configurable: false,
- enumerable: false,
- writable: false,
- });
- window::on(INIT_FUNC_NAME, evt => {
- if (!frameEventWnd) {
- // setupVaultId's first event is the frame's contentWindow
- frameEventWnd = evt::getRelatedTarget();
- } else {
- // setupVaultId's second event is the vaultId
- bridge.post('WriteVault', evt::getDetail(), INJECT_PAGE, frameEventWnd);
- frameEventWnd = null;
- }
- });
- bridge.addHandlers({
- /**
- * FF bug workaround to enable processing of sourceURL in injected page scripts
- */
- InjectList: IS_FIREFOX && injectList,
- /**
- * Writes the value into the isolated world of the intercepted window
- * @this {Node} window
- */
- VaultId(id) {
- this[VAULT_SEED_NAME] = { id, parent: window };
- },
- });
- export function injectPageSandbox(contentId, webId) {
- const { cloneInto } = global;
- /* A page can read our script's textContent in a same-origin iframe via DOMNodeRemoved event.
- * Directly preventing it would require redefining ~20 DOM methods in the parent.
- * Instead, we'll send the ids via a temporary handshakeId event, to which the web-bridge
- * will listen only during its initial phase using vault-protected DOM methods. */
- const handshakeId = getUniqIdSafe();
- const handshaker = () => {
- pageInjectable = true;
- bindEvents(contentId, webId, bridge, cloneInto);
- fireBridgeEvent(handshakeId + process.env.HANDSHAKE_ACK, [webId, contentId], cloneInto);
- };
- /* The vault contains safe methods that we got from the highest same-origin parent,
- * where our code ran at document_start so it definitely predated the page scripts. */
- let vaultId = window[VAULT_SEED_NAME];
- if (vaultId) {
- delete window[VAULT_SEED_NAME];
- vaultId = tellParentToWriteVault(vaultId.parent, vaultId.id);
- } else {
- vaultId = !IS_TOP
- && isSameOriginWindow(window.parent)
- && tellParentToWriteVault(window.parent, getUniqIdSafe())
- || '';
- }
- /* With `once` the listener is removed before DOMNodeInserted is dispatched by appendChild,
- * otherwise a same-origin parent page could use it to spoof the handshake. */
- window::on(handshakeId, handshaker, { capture: true, once: true });
- inject({
- code: `(${VMInitInjection}(${IS_FIREFOX},'${handshakeId}','${vaultId}'))()`
- + `\n//# sourceURL=${browser.runtime.getURL('sandbox/injected-web.js')}`,
- });
- // Clean up in case CSP prevented the script from running
- window::off(handshakeId, handshaker, true);
- }
- /**
- * @param {string} contentId
- * @param {string} webId
- * @param {VMGetInjectedData} data
- */
- export async function injectScripts(contentId, webId, data) {
- 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);
- 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);
- // If the script wants this specific realm, which is unavailable, we won't inject it at all
- if (realm) {
- const { pathMap } = script.custom;
- const realmData = realms[realm];
- realmData.lists[script.runAt].push(script); // 'start' or 'body' per getScriptsByURL()
- realmData.is = true;
- if (pathMap) bridge.pathMaps[id] = pathMap;
- bridge.allowScript(script);
- } else {
- bridge.failedIds.push(id);
- }
- return [script.dataKey, realm === INJECT_CONTENT];
- });
- const moreData = sendCmd('InjectionFeedback', {
- feedback,
- pageInjectable,
- feedId: data.feedId,
- });
- // saving while safe
- const getReadyState = hasMore && describeProperty(Document[PROTO], 'readyState').get;
- const hasInvoker = realms[INJECT_CONTENT].is;
- if (hasInvoker) {
- setupContentInvoker(contentId, webId);
- }
- // Using a callback to avoid a microtask tick when the root element exists or appears.
- await onElement('*', async () => {
- injectAll('start');
- const onBody = (pgLists.body.length || contLists.body.length)
- && onElement('body', injectAll, 'body');
- // document-end, -idle
- if (hasMore) {
- data = await moreData;
- if (data) await injectDelayedScripts(!hasInvoker && contentId, webId, data, getReadyState);
- }
- if (onBody) {
- await onBody;
- }
- realms = null;
- pgLists = null;
- contLists = null;
- });
- VMInitInjection = null; // release for GC
- }
- async function injectDelayedScripts(contentId, webId, { cache, scripts }, getReadyState) {
- assign(bridge.cache, cache);
- let needsInvoker;
- scripts::forEach(script => {
- const { code, runAt } = script;
- if (code && !pageInjectable) {
- bridge.failedIds::push(script.props.id);
- } else {
- (code ? pgLists : contLists)[runAt]::push(script);
- if (!code) needsInvoker = true;
- }
- script.stage = !code && runAt;
- });
- if (document::getReadyState() === 'loading') {
- await new PromiseSafe(resolve => {
- /* Since most sites listen to DOMContentLoaded on `document`, we let them run first
- * by listening on `window` which follows `document` when the event bubbles up. */
- window::on('DOMContentLoaded', resolve, { once: true });
- });
- }
- if (needsInvoker && contentId) {
- setupContentInvoker(contentId, webId);
- }
- scripts::forEach(bridge.allowScript);
- injectAll('end');
- injectAll('idle');
- }
- function inject(item) {
- const realScript = makeElem('script', item.code);
- let script = realScript;
- // Firefox ignores sourceURL comment when a syntax error occurs so we'll print the name manually
- let onError;
- if (IS_FIREFOX) {
- onError = e => {
- const { stack } = e.error;
- if (!stack || `${stack}`.includes(browser.runtime.getURL(''))) {
- log('error', [item.displayName], e.error);
- e.preventDefault();
- }
- };
- window::on('error', onError);
- }
- // Hiding the script's code from mutation events like DOMNodeInserted or DOMNodeRemoved
- if (attachShadow) {
- if (!elShadow) {
- elShadow = makeElem('div');
- elShadowRoot = elShadow::attachShadow({ mode: 'closed' });
- elShadowRoot::appendChild(makeElem('style', ':host { display: none !important }'));
- }
- elShadowRoot::appendChild(realScript);
- script = elShadow;
- }
- // When using declarativeContent there's no documentElement so we'll append to `document`
- if (!appendToRoot(script)) document::appendChild(script);
- if (onError) window::off('error', onError);
- if (attachShadow) realScript::remove();
- script::remove();
- }
- function injectAll(runAt) {
- for (const realm in realms) { /* proto is null */// eslint-disable-line guard-for-in
- const realmData = realms[realm];
- const items = realmData.lists[runAt];
- const { info } = realmData;
- if (items.length) {
- bridge.post('ScriptData', { info, items, runAt }, realm);
- if (realm === INJECT_PAGE && !IS_FIREFOX) {
- injectList(runAt);
- }
- }
- }
- if (runAt !== 'start' && contLists[runAt].length) {
- bridge.post('RunAt', runAt, INJECT_CONTENT);
- }
- }
- async function injectList(runAt) {
- const list = pgLists[runAt];
- // Not using for-of because we don't know if @@iterator is safe.
- for (let i = 0, item; (item = list[i]); i += 1) {
- if (item.code) {
- if (runAt === 'idle') await sendCmd('SetTimeout', 0);
- if (runAt === 'end') await 0;
- inject(item);
- item.code = '';
- }
- }
- }
- function setupContentInvoker(contentId, webId) {
- const invokeContent = VMInitInjection(IS_FIREFOX)(webId, contentId, bridge.onHandle);
- const postViaBridge = bridge.post;
- bridge.post = (cmd, params, realm, node) => {
- const fn = realm === INJECT_CONTENT
- ? invokeContent
- : postViaBridge;
- fn(cmd, params, undefined, node);
- };
- }
- function tellParentToWriteVault(parent, vaultId) {
- // In FF, content scripts running in a same-origin frame cannot directly call parent's functions
- // TODO: Use a single PointerEvent with `pointerType: vaultId` when strict_min_version >= 59
- parent::fire(new MouseEventSafe(INIT_FUNC_NAME, { relatedTarget: window }));
- parent::fire(new CustomEventSafe(INIT_FUNC_NAME, { detail: vaultId }));
- return vaultId;
- }
|