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 ExtensionCommon, ExtensionAPI, Glean, Services, XPCOMUtils, ExtensionUtils */
/* eslint-disable no-console */
var { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs"
);
const { ChannelWrapper } = Cu.getGlobalForObject(ExtensionParent);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
});
const DATA_LEAK_BLOCKER_RS_COLLECTION = "addons-data-leak-blocker-domains";
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"TEST_DOMAINS",
"extensions.data-leak-blocker@mozilla.com.testDomains",
"",
null,
value => {
try {
return new Set(
value
.split(",")
// Trim whitespaces.
.map(v => v.trim())
// Omit any empty entries.
.filter(el => el)
);
} catch {
// Return an empty set if parsing the value fails.
return new Set();
}
}
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"TESTING",
"extensions.data-leak-blocker@mozilla.com.testing",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"GLEAN_SUBMIT_TASK_TIMEOUT",
"extensions.data-leak-blocker@mozilla.com.gleanSubmitTaskTimeout",
5000
);
this.dataAbuseDetection = class extends ExtensionAPI {
deferredPingSubmitTask = null;
domainsSet = null;
fogMetricsInitialized = false;
requestObserverRegistered = false;
rsClient = null;
rsSyncListener = null;
get IsShuttingDown() {
return (
!this.extension ||
this.extension.hasShutdown ||
Services.startup.shuttingDown
);
}
onStartup() {
this.deferredPingSubmitTask = new lazy.DeferredTask(
() => this.submitPing(),
lazy.GLEAN_SUBMIT_TASK_TIMEOUT
);
ExtensionParent.browserStartupPromise
.then(() => {
if (this.IsShuttingDown) {
console.log(
"Data Leak Blocker initialization cancelled on detected extension or app shutdown"
);
return Promise.resolve();
}
this.registerFOGMetricsAndPings();
this.rsClient = lazy.RemoteSettings(DATA_LEAK_BLOCKER_RS_COLLECTION);
// Process existing RS entries.
return this.onRemoteSettingsSync();
})
.then(() => {
if (this.IsShuttingDown) {
console.log(
"Data Leak Blocker initialization cancelled on detected extension or app shutdown"
);
return;
}
Services.obs.addObserver(this, "http-on-modify-request");
this.requestObserverRegistered = true;
this.rsSyncListener = this.onRemoteSettingsSync.bind(this);
this.rsClient.on("sync", this.rsSyncListener);
// Submit any events that may be collected for this custom ping
// in a previous session if it wasn't already sent.
this.deferredPingSubmitTask.arm();
});
}
onShutdown(isAppShutdown) {
if (isAppShutdown) {
return;
}
if (this.requestObserverRegistered) {
Services.obs.removeObserver(this, "http-on-modify-request");
}
if (this.rsSyncListener) {
this.rsClient?.off("sync", this.rsSyncListener);
this.rsSyncListener = null;
}
this.rsClient = null;
if (this.deferredPingSubmitTask) {
this.deferredPingSubmitTask.finalize();
this.deferredPingSubmitTask = null;
}
}
async onRemoteSettingsSync() {
const entries = await this.rsClient
.get({ syncIfEmpty: false })
.catch(err => {
console.error(
`Failure to process ${DATA_LEAK_BLOCKER_RS_COLLECTION} RemoteSettings`,
err
);
return [];
});
const domains = new Set();
for (const entry of entries) {
if (!Array.isArray(entry.domains)) {
if (lazy.TESTING) {
console.debug(
"Ignoring invalid RemoteSettings entry ('domains' property invalid or missing)",
entry
);
}
continue;
}
for (const domain of entry.domains) {
if (!domain) {
if (lazy.TESTING) {
console.debug(
`Ignoring unxpected empty domain in ${DATA_LEAK_BLOCKER_RS_COLLECTION} record`,
entry
);
}
continue;
}
domains.add(domain);
}
}
this.domainsSet = domains;
if (lazy.TESTING) {
this.domainsSet = domains.union(lazy.TEST_DOMAINS);
console.debug(
"Data Leak Blocker DomainsSet updated",
Array.from(this.domainsSet)
);
}
}
registerFOGMetricsAndPings() {
// Register the custom ping to Glean (if not already registered).
//
// NOTE: this should be kept in sync with the ping as defined in the
// pings.yaml.
if (!("dataLeakBlocker" in GleanPings)) {
const ping = {
name: "data-leak-blocker",
includeClientId: true,
sendIfEmpty: false,
preciseTimestamp: false,
includeInfoSections: true,
enabled: true,
schedulesPings: [],
reasonCodes: [],
followsCollectionEnabled: true,
uploaderCapabilities: [],
};
Services.fog.registerRuntimePing(
ping.name,
ping.includeClientId,
ping.sendIfEmpty,
ping.preciseTimestamp,
ping.includeInfoSections,
ping.enabled,
ping.schedulesPings,
ping.reasonCodes,
ping.followsCollectionEnabled,
ping.uploaderCapabilities
);
}
// Register the custom metric to Glean (if not already registered).
//
// NOTE: this should be kept in sync with the metric as defined in the
// metrics.yaml.
if (!Glean.dataLeakBlocker?.reportV1) {
const metric = {
category: "data_leak_blocker",
name: "report_v1",
type: "event",
lifetime: "ping",
pings: ["data-leak-blocker"],
disabled: false,
extraArgs: {
allowed_extra_keys: [
"addon_id",
"blocked",
"content_policy_type",
"is_addon_triggering",
"is_addon_loading",
"is_content_script",
"method",
],
},
};
Services.fog.registerRuntimeMetric(
metric.type,
metric.category,
metric.name,
metric.pings,
`"${metric.lifetime}"`,
metric.disabled,
JSON.stringify(metric.extraArgs)
);
}
this.fogMetricsInitialized = true;
}
submitPing() {
// NOTE: optional chaining is used here because on artifacts builds the runtime-registered
// glean ping defined by the registerFOGMetricsAndPings method would be unregistered
// as a side-effect of the jogfile for the artifacts build metrics being loaded
// (See Bug 1983674).
GleanPings.dataLeakBlocker?.submit();
}
observe(subject, _topic, _data) {
try {
if (!this.domainsSet || !Glean.dataLeakBlocker?.reportV1) {
return;
}
this.processHttpOnModifyRequest(
subject.QueryInterface(Ci.nsIHttpChannel)
);
} catch (err) {
if (lazy.TESTING) {
console.error(
"Unexpected error on processing http-on-modify-request notification",
err
);
}
}
}
processHttpOnModifyRequest(channel) {
// Ignore internal favicon request triggered by FaviconLoader.sys.mjs.
if (
channel.loadInfo.internalContentPolicyType ===
Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
) {
return;
}
const { URI } = channel;
if (!URI.schemeIs("http") && !URI.schemeIs("https")) {
return;
}
const { triggeringPrincipal, loadingPrincipal, externalContentPolicyType } =
channel.loadInfo;
// Ignore requests that are not attributed to add-ons.
if (
!triggeringPrincipal.isAddonOrExpandedAddonPrincipal &&
!loadingPrincipal?.isAddonOrExpandedAddonPrincipal
) {
return;
}
if (!this.domainsSet.has(URI.host)) {
return;
}
// numeric nsContentPolicyType enum value.
let content_policy_type = externalContentPolicyType;
let is_addon_loading = false;
let is_addon_triggering = false;
let is_content_script = false;
let method = channel.requestMethod;
let addonPolicy;
if (triggeringPrincipal.isAddonOrExpandedAddonPrincipal) {
is_addon_triggering = true;
is_content_script = !!triggeringPrincipal.contentScriptAddonPolicy;
addonPolicy =
triggeringPrincipal.addonPolicy ??
triggeringPrincipal.contentScriptAddonPolicy;
} else if (loadingPrincipal?.isAddonOrExpandedAddonPrincipal) {
// Look for an addon id on the loadingPrincipal as a fallback
// (if it is defined, e.g. it is not defined for request triggered
// when a user loads an url in a new tab).
is_addon_loading = true;
is_content_script = !!loadingPrincipal.contentScriptAddonPolicy;
addonPolicy =
loadingPrincipal.addonPolicy ??
loadingPrincipal.contentScriptAddonPolicy;
} else {
// Bail out if we can't determine an addon id for the request.
return;
}
if (lazy.TESTING) {
console.debug("Detected request to suspicious domain", channel.name);
}
const channelWrapper = ChannelWrapper.get(channel);
channelWrapper.cancel(
Cr.NS_ERROR_ABORT,
Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
);
let properties = channel.QueryInterface(Ci.nsIWritablePropertyBag);
properties.setProperty("cancelledByExtension", this.extension.id);
const addon_id = addonPolicy?.id ?? "no-addon-id";
// NOTE: optional chaining is used here because on artifacts builds the runtime-registered
// glean ping defined by the registerFOGMetricsAndPings method would be unregistered
// as a side-effect of the jogfile for the artifacts build metrics being loaded
// (See Bug 1983674).
Glean.dataLeakBlocker?.reportV1.record({
addon_id,
is_addon_triggering,
is_addon_loading,
is_content_script,
method,
content_policy_type,
blocked: true,
});
this.deferredPingSubmitTask.arm();
if (lazy.TESTING) {
console.debug("Suspicious request details", {
name: channel.name,
addon_id,
is_addon_triggering,
is_addon_loading,
is_content_script,
method,
content_policy_type,
blocked: true,
});
}
}
};