summaryrefslogtreecommitdiffstats
path: root/browser/extensions/search-detection/extension/api.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/extensions/search-detection/extension/api.js264
1 files changed, 264 insertions, 0 deletions
diff --git a/browser/extensions/search-detection/extension/api.js b/browser/extensions/search-detection/extension/api.js
new file mode 100644
index 0000000000..873a2ecedd
--- /dev/null
+++ b/browser/extensions/search-detection/extension/api.js
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionCommon, ExtensionAPI, Services, XPCOMUtils, ExtensionUtils */
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonSearchEngine: "resource://gre/modules/AddonSearchEngine.sys.mjs",
+});
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+XPCOMUtils.defineLazyGlobalGetters(this, ["ChannelWrapper"]);
+
+XPCOMUtils.defineLazyGetter(this, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data === "init-complete"
+ );
+});
+
+const SEARCH_TOPIC_ENGINE_MODIFIED = "browser-search-engine-modified";
+
+this.addonsSearchDetection = class extends ExtensionAPI {
+ getAPI(context) {
+ const { extension } = context;
+
+ // We want to temporarily store the first monitored URLs that have been
+ // redirected, indexed by request IDs, so that the background script can
+ // find the add-on IDs to report.
+ this.firstMatchedUrls = {};
+
+ return {
+ addonsSearchDetection: {
+ // `getMatchPatterns()` returns a map where each key is an URL pattern
+ // to monitor and its corresponding value is a list of add-on IDs
+ // (search engines).
+ //
+ // Note: We don't return a simple list of URL patterns because the
+ // background script might want to lookup add-on IDs for a given URL in
+ // the case of server-side redirects.
+ async getMatchPatterns() {
+ const patterns = {};
+
+ try {
+ await searchInitialized;
+ const visibleEngines = await Services.search.getEngines();
+
+ visibleEngines.forEach(engine => {
+ if (!(engine instanceof lazy.AddonSearchEngine)) {
+ return;
+ }
+ const { _extensionID, _urls } = engine.wrappedJSObject;
+
+ if (!_extensionID) {
+ // OpenSearch engines don't have an extension ID.
+ return;
+ }
+
+ _urls
+ // We only want to collect "search URLs" (and not "suggestion"
+ // ones for instance). See `URL_TYPE` in `SearchUtils.jsm`.
+ .filter(({ type }) => type === "text/html")
+ .forEach(({ template }) => {
+ // If this is changed, double check the code in the background
+ // script because `webRequestCancelledHandler` splits patterns
+ // on `*` to retrieve URL prefixes.
+ const pattern = template.split("?")[0] + "*";
+
+ // Multiple search engines could register URL templates that
+ // would become the same URL pattern as defined above so we
+ // store a list of extension IDs per URL pattern.
+ if (!patterns[pattern]) {
+ patterns[pattern] = [];
+ }
+
+ // We exclude built-in search engines because we don't need
+ // to report them.
+ if (
+ !patterns[pattern].includes(_extensionID) &&
+ !_extensionID.endsWith("@search.mozilla.org")
+ ) {
+ patterns[pattern].push(_extensionID);
+ }
+ });
+ });
+ } catch (err) {
+ console.error(err);
+ }
+
+ return patterns;
+ },
+
+ // `getAddonVersion()` returns the add-on version if it exists.
+ async getAddonVersion(addonId) {
+ const addon = await AddonManager.getAddonByID(addonId);
+
+ return addon && addon.version;
+ },
+
+ // `getPublicSuffix()` returns the public suffix/Effective TLD Service
+ // of the given URL.
+ // See: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIEffectiveTLDService
+ async getPublicSuffix(url) {
+ try {
+ return Services.eTLD.getBaseDomain(Services.io.newURI(url));
+ } catch (err) {
+ console.error(err);
+ return null;
+ }
+ },
+
+ // `onSearchEngineModified` is an event that occurs when the list of
+ // search engines has changed, e.g., a new engine has been added or an
+ // engine has been removed.
+ //
+ // See: https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchUtils.jsm#145-152
+ onSearchEngineModified: new ExtensionCommon.EventManager({
+ context,
+ name: "addonsSearchDetection.onSearchEngineModified",
+ register: fire => {
+ const onSearchEngineModifiedObserver = (
+ aSubject,
+ aTopic,
+ aData
+ ) => {
+ if (
+ aTopic !== SEARCH_TOPIC_ENGINE_MODIFIED ||
+ // We are only interested in these modified types.
+ !["engine-added", "engine-removed", "engine-changed"].includes(
+ aData
+ )
+ ) {
+ return;
+ }
+
+ fire.async();
+ };
+
+ Services.obs.addObserver(
+ onSearchEngineModifiedObserver,
+ SEARCH_TOPIC_ENGINE_MODIFIED
+ );
+
+ return () => {
+ Services.obs.removeObserver(
+ onSearchEngineModifiedObserver,
+ SEARCH_TOPIC_ENGINE_MODIFIED
+ );
+ };
+ },
+ }).api(),
+
+ // `onRedirected` is an event fired after a request has stopped and
+ // this request has been redirected once or more. The registered
+ // listeners will received the following properties:
+ //
+ // - `addonId`: the add-on ID that redirected the request, if any.
+ // - `firstUrl`: the first monitored URL of the request that has
+ // been redirected.
+ // - `lastUrl`: the last URL loaded for the request, after one or
+ // more redirects.
+ onRedirected: new ExtensionCommon.EventManager({
+ context,
+ name: "addonsSearchDetection.onRedirected",
+ register: (fire, filter) => {
+ const stopListener = event => {
+ if (event.type != "stop") {
+ return;
+ }
+
+ const wrapper = event.currentTarget;
+ const { channel, id: requestId } = wrapper;
+
+ // When we detected a redirect, we read the request property,
+ // hoping to find an add-on ID corresponding to the add-on that
+ // initiated the redirect. It might not return anything when the
+ // redirect is a search server-side redirect but it can also be
+ // caused by an error.
+ let addonId;
+ try {
+ addonId = channel
+ ?.QueryInterface(Ci.nsIPropertyBag)
+ ?.getProperty("redirectedByExtension");
+ } catch (err) {
+ console.error(err);
+ }
+
+ const firstUrl = this.firstMatchedUrls[requestId];
+ // We don't need this entry anymore.
+ delete this.firstMatchedUrls[requestId];
+
+ const lastUrl = wrapper.finalURL;
+
+ if (!firstUrl || !lastUrl) {
+ // Something went wrong but there is nothing we can do at this
+ // point.
+ return;
+ }
+
+ fire.sync({ addonId, firstUrl, lastUrl });
+ };
+
+ const listener = ({ requestId, url, originUrl }) => {
+ // We exclude requests not originating from the location bar,
+ // bookmarks and other "system-ish" requests.
+ if (originUrl !== undefined) {
+ return;
+ }
+
+ // Keep the first monitored URL that was redirected for the
+ // request until the request has stopped.
+ if (!this.firstMatchedUrls[requestId]) {
+ this.firstMatchedUrls[requestId] = url;
+
+ const wrapper = ChannelWrapper.getRegisteredChannel(
+ requestId,
+ context.extension.policy,
+ context.xulBrowser.frameLoader.remoteTab
+ );
+
+ wrapper.addEventListener("stop", stopListener);
+ }
+ };
+
+ WebRequest.onBeforeRedirect.addListener(
+ listener,
+ // filter
+ {
+ types: ["main_frame"],
+ urls: ExtensionUtils.parseMatchPatterns(filter.urls),
+ },
+ // info
+ [],
+ // listener details
+ {
+ addonId: extension.id,
+ policy: extension.policy,
+ blockingAllowed: false,
+ }
+ );
+
+ return () => {
+ WebRequest.onBeforeRedirect.removeListener(listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};