summaryrefslogtreecommitdiffstats
path: root/browser/extensions/search-detection
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /browser/extensions/search-detection
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/search-detection')
-rw-r--r--browser/extensions/search-detection/extension/api.js264
-rw-r--r--browser/extensions/search-detection/extension/background.js178
-rw-r--r--browser/extensions/search-detection/extension/manifest.json32
-rw-r--r--browser/extensions/search-detection/extension/schema.json60
-rw-r--r--browser/extensions/search-detection/jar.mn7
-rw-r--r--browser/extensions/search-detection/moz.build10
-rw-r--r--browser/extensions/search-detection/tests/browser/.eslintrc.js7
-rw-r--r--browser/extensions/search-detection/tests/browser/browser.ini9
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js204
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_extension_loaded.js15
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js260
-rw-r--r--browser/extensions/search-detection/tests/browser/redirect.sjs32
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);
+}