Source code

Revision control

Copy as Markdown

Other Tools

/* 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();
});