diff options
Diffstat (limited to '')
-rw-r--r-- | browser/extensions/search-detection/extension/api.js | 264 |
1 files changed, 264 insertions, 0 deletions
diff --git a/browser/extensions/search-detection/extension/api.js b/browser/extensions/search-detection/extension/api.js new file mode 100644 index 0000000000..873a2ecedd --- /dev/null +++ b/browser/extensions/search-detection/extension/api.js @@ -0,0 +1,264 @@ +/* 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, Services, XPCOMUtils, ExtensionUtils */ + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { WebRequest } = ChromeUtils.importESModule( + "resource://gre/modules/WebRequest.sys.mjs" +); +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonSearchEngine: "resource://gre/modules/AddonSearchEngine.sys.mjs", +}); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +XPCOMUtils.defineLazyGlobalGetters(this, ["ChannelWrapper"]); + +XPCOMUtils.defineLazyGetter(this, "searchInitialized", () => { + if (Services.search.isInitialized) { + return Promise.resolve(); + } + + return ExtensionUtils.promiseObserved( + "browser-search-service", + (_, data) => data === "init-complete" + ); +}); + +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: { + // `getMatchPatterns()` returns a map where each key is an URL pattern + // to monitor and its corresponding value is a list of add-on IDs + // (search engines). + // + // 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 getMatchPatterns() { + const patterns = {}; + + try { + await searchInitialized; + const visibleEngines = await Services.search.getEngines(); + + visibleEngines.forEach(engine => { + if (!(engine instanceof lazy.AddonSearchEngine)) { + return; + } + const { _extensionID, _urls } = engine.wrappedJSObject; + + if (!_extensionID) { + // OpenSearch engines don't have an extension ID. + return; + } + + _urls + // We only want to collect "search URLs" (and not "suggestion" + // ones for instance). See `URL_TYPE` in `SearchUtils.jsm`. + .filter(({ type }) => type === "text/html") + .forEach(({ template }) => { + // If this is changed, double check the code in the background + // script because `webRequestCancelledHandler` splits patterns + // on `*` to retrieve URL prefixes. + const pattern = template.split("?")[0] + "*"; + + // Multiple search engines could register URL templates that + // would become the same URL pattern as defined above so we + // store a list of extension IDs per URL pattern. + if (!patterns[pattern]) { + patterns[pattern] = []; + } + + // We exclude built-in search engines because we don't need + // to report them. + if ( + !patterns[pattern].includes(_extensionID) && + !_extensionID.endsWith("@search.mozilla.org") + ) { + patterns[pattern].push(_extensionID); + } + }); + }); + } catch (err) { + console.error(err); + } + + return patterns; + }, + + // `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: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIEffectiveTLDService + async getPublicSuffix(url) { + try { + return Services.eTLD.getBaseDomain(Services.io.newURI(url)); + } catch (err) { + console.error(err); + return null; + } + }, + + // `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/source/toolkit/components/search/SearchUtils.jsm#145-152 + 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 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, + context.xulBrowser.frameLoader.remoteTab + ); + + wrapper.addEventListener("stop", stopListener); + } + }; + + WebRequest.onBeforeRedirect.addListener( + listener, + // filter + { + types: ["main_frame"], + urls: ExtensionUtils.parseMatchPatterns(filter.urls), + }, + // info + [], + // listener details + { + addonId: extension.id, + policy: extension.policy, + blockingAllowed: false, + } + ); + + return () => { + WebRequest.onBeforeRedirect.removeListener(listener); + }; + }, + }).api(), + }, + }; + } +}; |