/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /* global ExtensionCommon, ExtensionAPI, Glean, Services, XPCOMUtils, ExtensionUtils */ const { AddonManager } = ChromeUtils.importESModule( "resource://gre/modules/AddonManager.sys.mjs" ); const { WebRequest } = ChromeUtils.importESModule( "resource://gre/modules/WebRequest.sys.mjs" ); var { ExtensionParent } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionParent.sys.mjs" ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddonSearchEngine: "moz-src:///toolkit/components/search/AddonSearchEngine.sys.mjs", ConfigSearchEngine: "moz-src:///toolkit/components/search/ConfigSearchEngine.sys.mjs", SearchService: "moz-src:///toolkit/components/search/SearchService.sys.mjs", }); // eslint-disable-next-line mozilla/reject-importGlobalProperties XPCOMUtils.defineLazyGlobalGetters(this, ["ChannelWrapper", "URLSearchParams"]); const SEARCH_TOPIC_ENGINE_MODIFIED = "browser-search-engine-modified"; this.addonsSearchDetection = class extends ExtensionAPI { getAPI(context) { const { extension } = context; // We want to temporarily store the first monitored URLs that have been // redirected, indexed by request IDs, so that the background script can // find the add-on IDs to report. this.firstMatchedUrls = {}; return { addonsSearchDetection: { // `getEngines()` returns an array of engines, each with a baseUrl. // AppProvided engines also include a partner paramName, // while Addon provided engines include the addonId. // // Note: We don't return a simple list of URL patterns because the // background script might want to lookup add-on IDs for a given URL in // the case of server-side redirects. async getEngines() { const results = []; try { // Delaying accessing SearchService if we didn't get to first paint yet // to avoid triggering search internals from loading too soon during the // application startup. if ( !Cu.isESModuleLoaded( "moz-src:///toolkit/components/search/SearchService.sys.mjs" ) ) { await ExtensionParent.browserPaintedPromise; } // Return earlier if the extension or the application is shutting down. if (extension.hasShutdown || Services.startup.shuttingDown) { return results; } await lazy.SearchService.promiseInitialized; const engines = await lazy.SearchService.getEngines(); for (let engine of engines) { if ( !(engine instanceof lazy.AddonSearchEngine) && !(engine instanceof lazy.ConfigSearchEngine) ) { continue; } // The search term isn't used, but avoids a warning of an empty // term. let submission = engine.getSubmission("searchTerm"); if (submission) { const uri = submission.uri; const baseUrl = uri.prePath + uri.filePath; // We don't store ids for application provided search engines // because we don't need to report them. However, we do ensure // the pattern is recorded (above), so that we check for // redirects against those. const addonId = engine.extensionID; let paramName; for (let [key, value] of new URLSearchParams(uri.query)) { if (value && value === engine.partnerCode) { paramName = key; } } results.push({ baseUrl, addonId, paramName }); } } } catch (err) { console.error(err); } return results; }, // `getAddonVersion()` returns the add-on version if it exists. async getAddonVersion(addonId) { const addon = await AddonManager.getAddonByID(addonId); return addon && addon.version; }, // `getPublicSuffix()` returns the public suffix/Effective TLD Service // of the given URL. See: `nsIEffectiveTLDService` interface in tree. async getPublicSuffix(url) { try { return Services.eTLD.getBaseDomain(Services.io.newURI(url)); } catch (err) { console.error(err); return null; } }, reportSameSiteRedirect(extra) { Glean.addonsSearchDetection.sameSiteRedirect.record(extra); }, reportETLDChangeOther(extra) { Glean.addonsSearchDetection.etldChangeOther.record(extra); }, reportETLDChangeWebrequest(extra) { Glean.addonsSearchDetection.etldChangeWebrequest.record(extra); }, // `onSearchEngineModified` is an event that occurs when the list of // search engines has changed, e.g., a new engine has been added or an // engine has been removed. // // See: https://searchfox.org/mozilla-central/rev/cb44fc4f7bb84f2a18fedba64c8563770df13e34/toolkit/components/search/SearchUtils.sys.mjs#185-193 onSearchEngineModified: new ExtensionCommon.EventManager({ context, name: "addonsSearchDetection.onSearchEngineModified", register: fire => { const onSearchEngineModifiedObserver = ( aSubject, aTopic, aData ) => { if ( aTopic !== SEARCH_TOPIC_ENGINE_MODIFIED || // We are only interested in these modified types. !["engine-added", "engine-removed", "engine-changed"].includes( aData ) ) { return; } fire.async(); }; Services.obs.addObserver( onSearchEngineModifiedObserver, SEARCH_TOPIC_ENGINE_MODIFIED ); return () => { Services.obs.removeObserver( onSearchEngineModifiedObserver, SEARCH_TOPIC_ENGINE_MODIFIED ); }; }, }).api(), // `onRedirected` is an event fired after a request has stopped and // this request has been redirected once or more. The registered // listeners will received the following properties: // // - `addonId`: the add-on ID that redirected the request, if any. // - `firstUrl`: the first monitored URL of the request that has // been redirected. // - `lastUrl`: the last URL loaded for the request, after one or // more redirects. onRedirected: new ExtensionCommon.EventManager({ context, name: "addonsSearchDetection.onRedirected", register: (fire, filter) => { const stopListener = event => { if (event.type != "stop") { return; } const wrapper = event.currentTarget; const { channel, id: requestId } = wrapper; // When we detected a redirect, we read the request property, // hoping to find an add-on ID corresponding to the add-on that // initiated the redirect. It might not return anything when the // redirect is a search server-side redirect but it can also be // caused by an error. let addonId; try { addonId = channel ?.QueryInterface(Ci.nsIPropertyBag) ?.getProperty("redirectedByExtension"); } catch (err) { console.error(err); } const firstUrl = this.firstMatchedUrls[requestId]; // We don't need this entry anymore. delete this.firstMatchedUrls[requestId]; const lastUrl = wrapper.finalURL; if (!firstUrl || !lastUrl) { // Something went wrong but there is nothing we can do at this // point. return; } fire.sync({ addonId, firstUrl, lastUrl }); }; const remoteTab = context.xulBrowser.frameLoader.remoteTab; const listener = ({ requestId, url, originUrl }) => { // We exclude requests not originating from the location bar, // bookmarks and other "system-ish" requests. if (originUrl !== undefined) { return; } // Keep the first monitored URL that was redirected for the // request until the request has stopped. if (!this.firstMatchedUrls[requestId]) { this.firstMatchedUrls[requestId] = url; const wrapper = ChannelWrapper.getRegisteredChannel( requestId, context.extension.policy, remoteTab ); wrapper.addEventListener("stop", stopListener); } }; const ensureRegisterChannel = data => { // onRedirected depends on ChannelWrapper.getRegisteredChannel, // which in turn depends on registerTraceableChannel to have been // called. When a blocking webRequest listener is present, the // parent/ext-webRequest.js implementation already calls that. // // A downside to a blocking webRequest listener is that it delays // the network request until a roundtrip to the listener in the // extension process has happened. Since we don't need to handle // the onBeforeRequest event, avoid the overhead by handling the // event and registration here, in the parent process. data.registerTraceableChannel(extension.policy, remoteTab); }; const parsedFilter = { types: ["main_frame"], urls: ExtensionUtils.parseMatchPatterns(filter.urls), }; WebRequest.onBeforeRequest.addListener( ensureRegisterChannel, parsedFilter, // blocking is needed to unlock data.registerTraceableChannel. ["blocking"], { addonId: extension.id, policy: extension.policy, blockingAllowed: true, } ); WebRequest.onBeforeRedirect.addListener( listener, parsedFilter, // info [], // listener details { addonId: extension.id, policy: extension.policy, blockingAllowed: false, } ); return () => { WebRequest.onBeforeRequest.removeListener(ensureRegisterChannel); WebRequest.onBeforeRedirect.removeListener(listener); }; }, }).api(), }, }; } };