diff options
Diffstat (limited to 'browser/extensions/search-detection')
12 files changed, 1078 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 + } + } + } + ] + } + ] + } +] diff --git a/browser/extensions/search-detection/jar.mn b/browser/extensions/search-detection/jar.mn new file mode 100644 index 0000000000..377c2be080 --- /dev/null +++ b/browser/extensions/search-detection/jar.mn @@ -0,0 +1,7 @@ +# 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/. + +browser.jar: +% resource builtin-addons %builtin-addons/ contentaccessible=yes + builtin-addons/search-detection/ (extension/**) diff --git a/browser/extensions/search-detection/moz.build b/browser/extensions/search-detection/moz.build new file mode 100644 index 0000000000..7aa40597b1 --- /dev/null +++ b/browser/extensions/search-detection/moz.build @@ -0,0 +1,10 @@ +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] + +with Files("**"): + BUG_COMPONENT = ("WebExtensions", "General") diff --git a/browser/extensions/search-detection/tests/browser/.eslintrc.js b/browser/extensions/search-detection/tests/browser/.eslintrc.js new file mode 100644 index 0000000000..e57058ecb1 --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, +}; diff --git a/browser/extensions/search-detection/tests/browser/browser.ini b/browser/extensions/search-detection/tests/browser/browser.ini new file mode 100644 index 0000000000..1bd22fe386 --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/browser.ini @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = + redirect.sjs + +[browser_client_side_redirection.js] +[browser_extension_loaded.js] +[browser_server_side_redirection.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure diff --git a/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js b/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js new file mode 100644 index 0000000000..5dad39dba4 --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js @@ -0,0 +1,204 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const TELEMETRY_EVENTS_FILTERS = { + category: "addonsSearchDetection", + method: "etld_change", +}; + +// The search-detection built-in add-on registers dynamic events. +const TELEMETRY_TEST_UTILS_OPTIONS = { clear: true, process: "dynamic" }; + +async function testClientSideRedirect({ + background, + permissions, + telemetryExpected = false, +}) { + Services.telemetry.clearEvents(); + + // Load an extension that does a client-side redirect. We expect this + // extension to be reported in a Telemetry event when `telemetryExpected` is + // set to `true`. + const addonId = "some@addon-id"; + const addonVersion = "1.2.3"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + version: addonVersion, + browser_specific_settings: { gecko: { id: addonId } }, + permissions, + }, + useAddonManager: "temporary", + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Simulate a search (with the test search engine) by navigating to it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com/search?q=babar", + }, + () => {} + ); + + await extension.unload(); + + TelemetryTestUtils.assertEvents( + telemetryExpected + ? [ + { + object: "webrequest", + value: "extension", + extra: { + addonId, + addonVersion, + from: "example.com", + to: "mochi.test", + }, + }, + ] + : [], + TELEMETRY_EVENTS_FILTERS, + TELEMETRY_TEST_UTILS_OPTIONS + ); +} + +add_setup(async function () { + const searchEngineName = "test search engine"; + + let searchEngine; + + // This cleanup function has to be registered before the one registered + // internally by loadExtension, otherwise it is going to trigger a test + // failure (because it will be called too late). + registerCleanupFunction(async () => { + await searchEngine.unload(); + ok( + !Services.search.getEngineByName(searchEngineName), + "test search engine unregistered" + ); + }); + + searchEngine = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: searchEngineName, + keyword: "test", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + }, + // NOTE: the search extension needs to be installed through the + // AddonManager to be correctly unregistered when it is uninstalled. + useAddonManager: "temporary", + }); + + await searchEngine.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchEngine); + ok( + Services.search.getEngineByName(searchEngineName), + "test search engine registered" + ); +}); + +add_task(function test_onBeforeRequest() { + return testClientSideRedirect({ + background() { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: "http://mochi.test:8888/", + }; + }, + { urls: ["*://example.com/*"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"], + telemetryExpected: true, + }); +}); + +add_task(function test_onBeforeRequest_url_not_monitored() { + // Here, we load an extension that does a client-side redirect. Because this + // extension does not listen to the URL of the search engine registered + // above, we don't expect this extension to be reported in a Telemetry event. + return testClientSideRedirect({ + background() { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: "http://mochi.test:8888/", + }; + }, + { urls: ["*://google.com/*"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + permissions: ["webRequest", "webRequestBlocking", "*://google.com/*"], + telemetryExpected: false, + }); +}); + +add_task(function test_onHeadersReceived() { + return testClientSideRedirect({ + background() { + browser.webRequest.onHeadersReceived.addListener( + () => { + return { + redirectUrl: "http://mochi.test:8888/", + }; + }, + { urls: ["*://example.com/*"], types: ["main_frame"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"], + telemetryExpected: true, + }); +}); + +add_task(function test_onHeadersReceived_url_not_monitored() { + // Here, we load an extension that does a client-side redirect. Because this + // extension does not listen to the URL of the search engine registered + // above, we don't expect this extension to be reported in a Telemetry event. + return testClientSideRedirect({ + background() { + browser.webRequest.onHeadersReceived.addListener( + () => { + return { + redirectUrl: "http://mochi.test:8888/", + }; + }, + { urls: ["*://google.com/*"], types: ["main_frame"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + permissions: ["webRequest", "webRequestBlocking", "*://google.com/*"], + telemetryExpected: false, + }); +}); diff --git a/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js b/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js new file mode 100644 index 0000000000..65f6ed09a8 --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_searchDetection_isActive() { + let addon = await AddonManager.getAddonByID( + "addons-search-detection@mozilla.com" + ); + + ok(addon, "Add-on exists"); + ok(addon.isActive, "Add-on is active"); + ok(addon.isBuiltin, "Add-on is built-in"); + ok(addon.hidden, "Add-on is hidden"); +}); diff --git a/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js b/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js new file mode 100644 index 0000000000..ea235406a4 --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js @@ -0,0 +1,260 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const TELEMETRY_EVENTS_FILTERS = { + category: "addonsSearchDetection", + method: "etld_change", +}; + +// The search-detection built-in add-on registers dynamic events. +const TELEMETRY_TEST_UTILS_OPTIONS = { clear: true, process: "dynamic" }; + +const REDIRECT_SJS = + "browser/browser/extensions/search-detection/tests/browser/redirect.sjs?q={searchTerms}"; +// This URL will redirect to `example.net`, which is different than +// `*.example.com`. That will be the final URL of a redirect chain: +// www.example.com -> example.net +const SEARCH_URL_WWW = `https://www.example.com/${REDIRECT_SJS}`; +// This URL will redirect to `www.example.com`, which will create a redirect +// chain with two hops: +// test2.example.com -> www.example.com -> example.net +const SEARCH_URL_TEST2 = `https://test2.example.com/${REDIRECT_SJS}`; +// This URL will redirect to `test2.example.com`, which will create a redirect +// chain with three hops: +// test1.example.com -> test2.example.com -> www.example.com -> example.net +const SEARCH_URL_TEST1 = `https://test1.example.com/${REDIRECT_SJS}`; + +const TEST_SEARCH_ENGINE_ADDON_ID = "some@addon-id"; +const TEST_SEARCH_ENGINE_ADDON_VERSION = "4.5.6"; + +const testServerSideRedirect = async ({ + searchURL, + expectedEvents, + tabURL, +}) => { + Services.telemetry.clearEvents(); + + const searchEngineName = "test search engine"; + // Load a default search engine because the add-on we are testing here + // monitors the search engines. + const searchEngine = ExtensionTestUtils.loadExtension({ + manifest: { + version: TEST_SEARCH_ENGINE_ADDON_VERSION, + browser_specific_settings: { + gecko: { id: TEST_SEARCH_ENGINE_ADDON_ID }, + }, + chrome_settings_overrides: { + search_provider: { + name: searchEngineName, + keyword: "test", + search_url: searchURL, + }, + }, + }, + useAddonManager: "temporary", + }); + + await searchEngine.startup(); + ok( + Services.search.getEngineByName(searchEngineName), + "test search engine registered" + ); + await AddonTestUtils.waitForSearchProviderStartup(searchEngine); + + // Simulate a search (with the test search engine) by navigating to it. + const url = tabURL || searchURL.replace("{searchTerms}", "some terms"); + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait for the tab to be fully loaded. + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, url); + await loaded; + }); + + await searchEngine.unload(); + ok( + !Services.search.getEngineByName(searchEngineName), + "test search engine unregistered" + ); + + TelemetryTestUtils.assertEvents( + expectedEvents, + TELEMETRY_EVENTS_FILTERS, + TELEMETRY_TEST_UTILS_OPTIONS + ); +}; + +add_task(function test_redirect_final() { + return testServerSideRedirect({ + // www.example.com -> example.net + searchURL: SEARCH_URL_WWW, + expectedEvents: [ + { + object: "other", + value: "server", + extra: { + addonId: TEST_SEARCH_ENGINE_ADDON_ID, + addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION, + from: "example.com", + to: "example.net", + }, + }, + ], + }); +}); + +add_task(function test_redirect_two_hops() { + return testServerSideRedirect({ + // test2.example.com -> www.example.com -> example.net + searchURL: SEARCH_URL_TEST2, + expectedEvents: [ + { + object: "other", + value: "server", + extra: { + addonId: TEST_SEARCH_ENGINE_ADDON_ID, + addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION, + from: "example.com", + to: "example.net", + }, + }, + ], + }); +}); + +add_task(function test_redirect_three_hops() { + return testServerSideRedirect({ + // test1.example.com -> test2.example.com -> www.example.com -> example.net + searchURL: SEARCH_URL_TEST1, + expectedEvents: [ + { + object: "other", + value: "server", + extra: { + addonId: TEST_SEARCH_ENGINE_ADDON_ID, + addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION, + from: "example.com", + to: "example.net", + }, + }, + ], + }); +}); + +add_task(function test_no_event_when_search_engine_not_used() { + return testServerSideRedirect({ + // www.example.com -> example.net + searchURL: SEARCH_URL_WWW, + // We do not expect any events because the user is not using the search + // engine that was registered. + tabURL: "http://mochi.test:8888/search?q=foobar", + expectedEvents: [], + }); +}); + +add_task(function test_redirect_chain_does_not_start_on_first_request() { + return testServerSideRedirect({ + // www.example.com -> example.net + searchURL: SEARCH_URL_WWW, + // User first navigates to an URL that isn't monitored and will be + // redirected to another URL that is monitored. + tabURL: `http://mochi.test:8888/browser/browser/extensions/search-detection/tests/browser/redirect.sjs?q={searchTerms}`, + expectedEvents: [ + { + object: "other", + value: "server", + extra: { + addonId: TEST_SEARCH_ENGINE_ADDON_ID, + addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION, + // We expect this and not `mochi.test` because we do not monitor + // `mochi.test`, only `example.com`, which is coming from the search + // engine registered in the test setup. + from: "example.com", + to: "example.net", + }, + }, + ], + }); +}); + +add_task(async function test_two_extensions_reported() { + Services.telemetry.clearEvents(); + + const searchEngines = []; + for (const [addonId, addonVersion, isDefault] of [ + ["1-addon@guid", "1.2", false], + ["2-addon@guid", "3.4", true], + ]) { + const searchEngine = ExtensionTestUtils.loadExtension({ + manifest: { + version: addonVersion, + browser_specific_settings: { + gecko: { id: addonId }, + }, + chrome_settings_overrides: { + search_provider: { + is_default: isDefault, + name: `test search engine - ${addonId}`, + keyword: "test", + search_url: `${SEARCH_URL_WWW}&id=${addonId}`, + }, + }, + }, + useAddonManager: "temporary", + }); + + await searchEngine.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchEngine); + + searchEngines.push(searchEngine); + } + + // Simulate a search by navigating to it. + const url = SEARCH_URL_WWW.replace("{searchTerms}", "some terms"); + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait for the tab to be fully loaded. + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, url); + await loaded; + }); + + await Promise.all(searchEngines.map(engine => engine.unload())); + + TelemetryTestUtils.assertEvents( + [ + { + object: "other", + value: "server", + extra: { + addonId: "1-addon@guid", + addonVersion: "1.2", + from: "example.com", + to: "example.net", + }, + }, + { + object: "other", + value: "server", + extra: { + addonId: "2-addon@guid", + addonVersion: "3.4", + from: "example.com", + to: "example.net", + }, + }, + ], + TELEMETRY_EVENTS_FILTERS, + TELEMETRY_TEST_UTILS_OPTIONS + ); +}); diff --git a/browser/extensions/search-detection/tests/browser/redirect.sjs b/browser/extensions/search-detection/tests/browser/redirect.sjs new file mode 100644 index 0000000000..27cb29b32e --- /dev/null +++ b/browser/extensions/search-detection/tests/browser/redirect.sjs @@ -0,0 +1,32 @@ +const REDIRECT_SJS = + "browser/browser/extensions/search-detection/tests/browser/redirect.sjs"; + +// This handler is used to create redirect chains with multiple sub-domains, +// and the next hop is defined by the current `host`. +function handleRequest(request, response) { + let newLocation; + + // test1.example.com -> test2.example.com -> www.example.com -> example.net + switch (request.host) { + case "test1.example.com": + newLocation = `https://test2.example.com/${REDIRECT_SJS}`; + break; + case "test2.example.com": + newLocation = `https://www.example.com/${REDIRECT_SJS}`; + break; + case "www.example.com": + newLocation = "https://example.net/"; + break; + // We redirect `mochi.test` to `www` in + // `test_redirect_chain_does_not_start_on_first_request()`. + case "mochi.test": + newLocation = `https://www.example.com/${REDIRECT_SJS}`; + break; + default: + // Redirect to a different website in case of unexpected events. + newLocation = "https://mozilla.org/"; + } + + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", newLocation); +} |