diff options
Diffstat (limited to 'browser/extensions/search-detection/extension')
4 files changed, 534 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(), + }, + }; + } +}; diff --git a/browser/extensions/search-detection/extension/background.js b/browser/extensions/search-detection/extension/background.js new file mode 100644 index 0000000000..043bb0243f --- /dev/null +++ b/browser/extensions/search-detection/extension/background.js @@ -0,0 +1,178 @@ +/* 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 browser */ + +const TELEMETRY_CATEGORY = "addonsSearchDetection"; +// methods +const TELEMETRY_METHOD_ETLD_CHANGE = "etld_change"; +// objects +const TELEMETRY_OBJECT_WEBREQUEST = "webrequest"; +const TELEMETRY_OBJECT_OTHER = "other"; +// values +const TELEMETRY_VALUE_EXTENSION = "extension"; +const TELEMETRY_VALUE_SERVER = "server"; + +class AddonsSearchDetection { + constructor() { + // The key is an URL pattern to monitor and its corresponding value is a + // list of add-on IDs. + this.matchPatterns = {}; + + browser.telemetry.registerEvents(TELEMETRY_CATEGORY, { + [TELEMETRY_METHOD_ETLD_CHANGE]: { + methods: [TELEMETRY_METHOD_ETLD_CHANGE], + objects: [TELEMETRY_OBJECT_WEBREQUEST, TELEMETRY_OBJECT_OTHER], + extra_keys: ["addonId", "addonVersion", "from", "to"], + record_on_release: true, + }, + }); + + this.onRedirectedListener = this.onRedirectedListener.bind(this); + } + + async getMatchPatterns() { + try { + this.matchPatterns = + await browser.addonsSearchDetection.getMatchPatterns(); + } catch (err) { + console.error(`failed to retrieve the list of URL patterns: ${err}`); + this.matchPatterns = {}; + } + + return this.matchPatterns; + } + + // When the search service changes the set of engines that are enabled, we + // update our pattern matching in the webrequest listeners (go to the bottom + // of this file for the search service events we listen to). + async monitor() { + // If there is already a listener, remove it so that we can re-add one + // after. This is because we're using the same listener with different URL + // patterns (when the list of search engines changes). + if ( + browser.addonsSearchDetection.onRedirected.hasListener( + this.onRedirectedListener + ) + ) { + browser.addonsSearchDetection.onRedirected.removeListener( + this.onRedirectedListener + ); + } + // If there is already a listener, remove it so that we can re-add one + // after. This is because we're using the same listener with different URL + // patterns (when the list of search engines changes). + if (browser.webRequest.onBeforeRequest.hasListener(this.noOpListener)) { + browser.webRequest.onBeforeRequest.removeListener(this.noOpListener); + } + + // Retrieve the list of URL patterns to monitor with our listener. + // + // Note: search suggestions are system principal requests, so webRequest + // cannot intercept them. + const matchPatterns = await this.getMatchPatterns(); + const patterns = Object.keys(matchPatterns); + + if (patterns.length === 0) { + return; + } + + browser.webRequest.onBeforeRequest.addListener( + this.noOpListener, + { types: ["main_frame"], urls: patterns }, + ["blocking"] + ); + + browser.addonsSearchDetection.onRedirected.addListener( + this.onRedirectedListener, + { urls: patterns } + ); + } + + // This listener is required to force the registration of traceable channels. + noOpListener() { + // Do nothing. + } + + async onRedirectedListener({ addonId, firstUrl, lastUrl }) { + // When we do not have an add-on ID (in the request property bag), we + // likely detected a search server-side redirect. + const maybeServerSideRedirect = !addonId; + + let addonIds = []; + // Search server-side redirects are possible because an extension has + // registered a search engine, which is why we can (hopefully) retrieve the + // add-on ID. + if (maybeServerSideRedirect) { + addonIds = this.getAddonIdsForUrl(firstUrl); + } else if (addonId) { + addonIds = [addonId]; + } + + if (addonIds.length === 0) { + // No add-on ID means there is nothing we can report. + return; + } + + // This is the monitored URL that was first redirected. + const from = await browser.addonsSearchDetection.getPublicSuffix(firstUrl); + // This is the final URL after redirect(s). + const to = await browser.addonsSearchDetection.getPublicSuffix(lastUrl); + + if (from === to) { + // We do not want to report redirects to same public suffixes. However, + // we will report redirects from public suffixes belonging to a same + // entity (.e.g., `example.com` -> `example.fr`). + // + // Known limitation: if a redirect chain starts and ends with the same + // public suffix, we won't report any event, even if the chain contains + // different public suffixes in between. + return; + } + + const telemetryObject = maybeServerSideRedirect + ? TELEMETRY_OBJECT_OTHER + : TELEMETRY_OBJECT_WEBREQUEST; + const telemetryValue = maybeServerSideRedirect + ? TELEMETRY_VALUE_SERVER + : TELEMETRY_VALUE_EXTENSION; + + for (const id of addonIds) { + const addonVersion = await browser.addonsSearchDetection.getAddonVersion( + id + ); + const extra = { addonId: id, addonVersion, from, to }; + + browser.telemetry.recordEvent( + TELEMETRY_CATEGORY, + TELEMETRY_METHOD_ETLD_CHANGE, + telemetryObject, + telemetryValue, + extra + ); + } + } + + getAddonIdsForUrl(url) { + for (const pattern of Object.keys(this.matchPatterns)) { + // `getMatchPatterns()` returns the prefix plus "*". + const urlPrefix = pattern.slice(0, -1); + + if (url.startsWith(urlPrefix)) { + return this.matchPatterns[pattern]; + } + } + + return []; + } +} + +const exp = new AddonsSearchDetection(); +exp.monitor(); + +browser.addonsSearchDetection.onSearchEngineModified.addListener(async () => { + await exp.monitor(); +}); diff --git a/browser/extensions/search-detection/extension/manifest.json b/browser/extensions/search-detection/extension/manifest.json new file mode 100644 index 0000000000..bbfa304654 --- /dev/null +++ b/browser/extensions/search-detection/extension/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 2, + "name": "Add-ons Search Detection", + "hidden": true, + "browser_specific_settings": { + "gecko": { + "id": "addons-search-detection@mozilla.com" + } + }, + "version": "2.0.0", + "description": "", + "experiment_apis": { + "addonsSearchDetection": { + "schema": "schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "api.js", + "events": [], + "paths": [["addonsSearchDetection"]] + } + } + }, + "permissions": [ + "<all_urls>", + "telemetry", + "webRequest", + "webRequestBlocking" + ], + "background": { + "scripts": ["background.js"] + } +} diff --git a/browser/extensions/search-detection/extension/schema.json b/browser/extensions/search-detection/extension/schema.json new file mode 100644 index 0000000000..e3c77e3f3d --- /dev/null +++ b/browser/extensions/search-detection/extension/schema.json @@ -0,0 +1,60 @@ +[ + { + "namespace": "addonsSearchDetection", + "functions": [ + { + "name": "getMatchPatterns", + "type": "function", + "async": true, + "parameters": [] + }, + { + "name": "getAddonVersion", + "type": "function", + "async": true, + "parameters": [{ "name": "addonId", "type": "string" }] + }, + { + "name": "getPublicSuffix", + "type": "function", + "async": true, + "parameters": [{ "name": "url", "type": "string" }] + } + ], + "events": [ + { + "name": "onSearchEngineModified", + "type": "function", + "parameters": [] + }, + { + "name": "onRedirected", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "addonId": { "type": "string" }, + "firstUrl": { "type": "string" }, + "lastUrl": { "type": "string" } + } + } + ], + "extraParameters": [ + { + "name": "filter", + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + } + } + ] + } + ] + } +] |