summaryrefslogtreecommitdiffstats
path: root/browser/extensions/search-detection/extension
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/search-detection/extension')
-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
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
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]