gecko-dev/browser/extensions/webcompat/lib/shims.js
Thomas Wisniewski b97066dd63 Bug 1714180 - fix a logic flaw in SmartBlock which makes it more permissive than necessary; r=denschub,webcompat-reviewers
The bug in question is that if a user opts into allowing one tracker on a given
site for the session, new visits to that site will allow others trackers as
well, not just the expected one.

While we're here, we also improve the web console messages to show the same
"allowing X due to opt in" message on new visits as well, not just the original
one where the user initially opted-in for that shim.

We might as well also land bug 1712959 into m-c, rather than waiting for the
next webcompat release cycle.

Differential Revision: https://phabricator.services.mozilla.com/D116642
2021-06-02 20:10:52 +00:00

626 lines
17 KiB
JavaScript

/* 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";
/* globals browser, module, onMessageFromTab */
// To grant shims access to bundled logo images without risking
// exposing our moz-extension URL, we have the shim request them via
// nonsense URLs which we then redirect to the actual files (but only
// on tabs where a shim using a given logo happens to be active).
const LogosBaseURL = "https://smartblock.firefox.etp/";
const releaseBranchPromise = browser.appConstants.getReleaseBranch();
const platformPromise = browser.runtime.getPlatformInfo().then(info => {
return info.os === "android" ? "android" : "desktop";
});
let debug = async function() {
if ((await releaseBranchPromise) !== "release_or_beta") {
console.debug.apply(this, arguments);
}
};
let error = async function() {
if ((await releaseBranchPromise) !== "release_or_beta") {
console.error.apply(this, arguments);
}
};
let warn = async function() {
if ((await releaseBranchPromise) !== "release_or_beta") {
console.warn.apply(this, arguments);
}
};
class Shim {
constructor(opts) {
const { matches, unblocksOnOptIn } = opts;
this.branches = opts.branches;
this.bug = opts.bug;
this.file = opts.file;
this.hosts = opts.hosts;
this.id = opts.id;
this.logos = opts.logos || [];
this.matches = [];
this.name = opts.name;
this.notHosts = opts.notHosts;
this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP;
this._options = opts.options || {};
this.needsShimHelpers = opts.needsShimHelpers;
this.platform = opts.platform || "all";
this.unblocksOnOptIn = unblocksOnOptIn;
this._hostOptIns = new Set();
this._disabledByConfig = opts.disabled;
this._disabledGlobally = false;
this._disabledByPlatform = false;
this._disabledByReleaseBranch = false;
this._activeOnTabs = new Set();
this._showedOptInOnTabs = new Set();
const pref = `disabled_shims.${this.id}`;
for (const match of matches) {
if (!match.types) {
this.matches.push({ patterns: [match], types: ["script"] });
} else {
this.matches.push(match);
}
}
browser.aboutConfigPrefs.onPrefChange.addListener(async () => {
const value = await browser.aboutConfigPrefs.getPref(pref);
this._disabledPrefValue = value;
this._onEnabledStateChanged();
}, pref);
this.ready = Promise.all([
browser.aboutConfigPrefs.getPref(pref),
platformPromise,
releaseBranchPromise,
]).then(([disabledPrefValue, platform, branch]) => {
this._disabledPrefValue = disabledPrefValue;
this._disabledByPlatform =
this.platform !== "all" && this.platform !== platform;
this._disabledByReleaseBranch = false;
for (const supportedBranchAndPlatform of this.branches || []) {
const [
supportedBranch,
supportedPlatform,
] = supportedBranchAndPlatform.split(":");
if (
(!supportedPlatform || supportedPlatform == platform) &&
supportedBranch != branch
) {
this._disabledByReleaseBranch = true;
}
}
this._preprocessOptions(platform, branch);
this._onEnabledStateChanged();
});
}
_preprocessOptions(platform, branch) {
// options may be any value, but can optionally be gated for specified
// platform/branches, if in the format `{value, branches, platform}`
this.options = {};
for (const [k, v] of Object.entries(this._options)) {
if (v?.value) {
if (
(!v.platform || v.platform === platform) &&
(!v.branches || v.branches.includes(branch))
) {
this.options[k] = v.value;
}
} else {
this.options[k] = v;
}
}
}
get enabled() {
if (this._disabledGlobally) {
return false;
}
if (this._disabledPrefValue !== undefined) {
return !this._disabledPrefValue;
}
return (
!this._disabledByConfig &&
!this._disabledByPlatform &&
!this._disabledByReleaseBranch
);
}
enable() {
this._disabledGlobally = false;
this._onEnabledStateChanged();
}
disable() {
this._disabledGlobally = true;
this._onEnabledStateChanged();
}
_onEnabledStateChanged() {
if (!this.enabled) {
return this._revokeRequestsInETP();
}
return this._allowRequestsInETP();
}
async _allowRequestsInETP() {
const matches = this.matches.map(m => m.patterns).flat();
if (matches.length) {
await browser.trackingProtection.shim(this.id, matches);
}
if (this._hostOptIns.size) {
const optIns = this.getApplicableOptIns();
if (optIns.length) {
await browser.trackingProtection.allow(
this.id,
this._optInPatterns,
Array.from(this._hostOptIns)
);
}
}
}
_revokeRequestsInETP() {
return browser.trackingProtection.revoke(this.id);
}
setActiveOnTab(tabId, active = true) {
if (active) {
this._activeOnTabs.add(tabId);
} else {
this._activeOnTabs.delete(tabId);
this._showedOptInOnTabs.delete(tabId);
}
}
isActiveOnTab(tabId) {
return this._activeOnTabs.has(tabId);
}
meantForHost(host) {
const { hosts, notHosts } = this;
if (hosts || notHosts) {
if (
(notHosts && notHosts.includes(host)) ||
(hosts && !hosts.includes(host))
) {
return false;
}
}
return true;
}
async unblocksURLOnOptIn(url) {
if (!this._optInPatterns) {
this._optInPatterns = await this.getApplicableOptIns();
}
if (!this._optInMatcher) {
this._optInMatcher = browser.matchPatterns.getMatcher(
Array.from(this._optInPatterns)
);
}
return this._optInMatcher.matches(url);
}
isTriggeredByURLAndType(url, type) {
for (const entry of this.matches || []) {
if (!entry.types.includes(type)) {
continue;
}
if (!entry.matcher) {
entry.matcher = browser.matchPatterns.getMatcher(
Array.from(entry.patterns)
);
}
if (entry.matcher.matches(url)) {
return entry;
}
}
return undefined;
}
async getApplicableOptIns() {
if (this._applicableOptIns) {
return this._applicableOptIns;
}
const optins = [];
for (const unblock of this.unblocksOnOptIn || []) {
if (typeof unblock === "string") {
optins.push(unblock);
continue;
}
const { branches, patterns, platforms } = unblock;
if (platforms?.length) {
const platform = await platformPromise;
if (platform !== "all" && !platforms.includes(platform)) {
continue;
}
}
if (branches?.length) {
const branch = await releaseBranchPromise;
if (!branches.includes(branch)) {
continue;
}
}
optins.push.apply(optins, patterns);
}
this._applicableOptIns = optins;
return optins;
}
async onUserOptIn(host) {
const optins = await this.getApplicableOptIns();
if (optins.length) {
this.userHasOptedIn = true;
this._hostOptIns.add(host);
await browser.trackingProtection.allow(
this.id,
optins,
Array.from(this._hostOptIns)
);
}
}
hasUserOptedInAlready(host) {
return this._hostOptIns.has(host);
}
showOptInWarningOnce(tabId, origin) {
if (this._showedOptInOnTabs.has(tabId)) {
return Promise.resolve();
}
this._showedOptInOnTabs.add(tabId);
const { bug, name } = this;
const warning = `${name} is allowed on ${origin} for this browsing session due to user opt-in. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
return browser.tabs
.executeScript(tabId, {
code: `console.warn(${JSON.stringify(warning)})`,
runAt: "document_start",
})
.catch(() => {});
}
}
class Shims {
constructor(availableShims) {
if (!browser.trackingProtection) {
console.error("Required experimental add-on APIs for shims unavailable");
return;
}
this._registerShims(availableShims);
onMessageFromTab(this._onMessageFromShim.bind(this));
this.ENABLED_PREF = "enable_shims";
browser.aboutConfigPrefs.onPrefChange.addListener(() => {
this._checkEnabledPref();
}, this.ENABLED_PREF);
this._haveCheckedEnabledPref = this._checkEnabledPref();
}
_registerShims(shims) {
if (this.shims) {
throw new Error("_registerShims has already been called");
}
this.shims = new Map();
for (const shimOpts of shims) {
const { id } = shimOpts;
if (!this.shims.has(id)) {
this.shims.set(shimOpts.id, new Shim(shimOpts));
}
}
const allMatchTypePatterns = new Map();
const allLogos = [];
for (const shim of this.shims.values()) {
const { logos, matches } = shim;
allLogos.push(...logos);
for (const { patterns, types } of matches || []) {
for (const type of types) {
if (!allMatchTypePatterns.has(type)) {
allMatchTypePatterns.set(type, { patterns: new Set() });
}
const allSet = allMatchTypePatterns.get(type).patterns;
for (const pattern of patterns) {
allSet.add(pattern);
}
}
}
}
if (allLogos.length) {
const urls = Array.from(new Set(allLogos)).map(l => {
return `${LogosBaseURL}${l}`;
});
debug("Allowing access to these logos:", urls);
const unmarkShimsActive = tabId => {
for (const shim of this.shims.values()) {
shim.setActiveOnTab(tabId, false);
}
};
browser.tabs.onRemoved.addListener(unmarkShimsActive);
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.discarded || changeInfo.url) {
unmarkShimsActive(tabId);
}
});
browser.webRequest.onBeforeRequest.addListener(
this._redirectLogos.bind(this),
{ urls, types: ["image"] },
["blocking"]
);
}
if (!allMatchTypePatterns.size) {
debug("Skipping shims; none enabled");
return;
}
for (const [type, { patterns }] of allMatchTypePatterns.entries()) {
const urls = Array.from(patterns);
debug("Shimming these", type, "URLs:", urls);
browser.webRequest.onBeforeRequest.addListener(
this._ensureShimForRequestOnTab.bind(this),
{ urls, types: [type] },
["blocking"]
);
}
}
async _checkEnabledPref() {
await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => {
if (value === undefined) {
browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true);
} else if (value === false) {
this.enabled = false;
} else {
this.enabled = true;
}
});
}
get enabled() {
return this._enabled;
}
set enabled(enabled) {
if (enabled === this._enabled) {
return;
}
this._enabled = enabled;
for (const shim of this.shims.values()) {
if (enabled) {
shim.enable();
} else {
shim.disable();
}
}
}
async _onMessageFromShim(payload, sender, sendResponse) {
const { tab, frameId } = sender;
const { id, url } = tab;
const { shimId, message } = payload;
// Ignore unknown messages (for instance, from about:compat).
if (message !== "getOptions" && message !== "optIn") {
return undefined;
}
if (sender.id !== browser.runtime.id || id === -1) {
throw new Error("not allowed");
}
// Important! It is entirely possible for sites to spoof
// these messages, due to shims allowing web pages to
// communicate with the extension.
const shim = this.shims.get(shimId);
if (!shim?.needsShimHelpers?.includes(message)) {
throw new Error("not allowed");
}
if (message === "getOptions") {
return Object.assign(
{
platform: await platformPromise,
releaseBranch: await releaseBranchPromise,
},
shim.options
);
} else if (message === "optIn") {
try {
await shim.onUserOptIn(new URL(url).hostname);
const origin = new URL(tab.url).origin;
warn(
"** User opted in for",
shim.name,
"shim on",
origin,
"on tab",
id,
"frame",
frameId
);
await shim.showOptInWarningOnce(id, origin);
} catch (err) {
console.error(err);
throw new Error("error");
}
}
return undefined;
}
async _redirectLogos(details) {
await this._haveCheckedEnabledPref;
if (!this.enabled) {
return { cancel: true };
}
const { tabId, url } = details;
const logo = new URL(url).pathname.slice(1);
for (const shim of this.shims.values()) {
await shim.ready;
if (!shim.enabled) {
continue;
}
if (!shim.logos.includes(logo)) {
continue;
}
if (shim.isActiveOnTab(tabId)) {
return { redirectUrl: browser.runtime.getURL(`shims/${logo}`) };
}
}
return { cancel: true };
}
async _ensureShimForRequestOnTab(details) {
await this._haveCheckedEnabledPref;
if (!this.enabled) {
return undefined;
}
// We only ever reach this point if a request is for a URL which ought to
// be shimmed. We never get here if a request is blocked, and we only
// unblock requests if at least one shim matches it.
const { frameId, originUrl, requestId, tabId, type, url } = details;
// Ignore requests unrelated to tabs
if (tabId < 0) {
return undefined;
}
// We need to base our checks not on the frame's host, but the tab's.
const topHost = new URL((await browser.tabs.get(tabId)).url).hostname;
const unblocked = await browser.trackingProtection.wasRequestUnblocked(
requestId
);
let match;
let shimToApply;
for (const shim of this.shims.values()) {
await shim.ready;
if (!shim.enabled) {
continue;
}
// Do not apply the shim if it is only meant to apply when strict mode ETP
// (content blocking) was going to block the request.
if (!unblocked && shim.onlyIfBlockedByETP) {
continue;
}
if (!shim.meantForHost(topHost)) {
continue;
}
// If this URL and content type isn't meant for this shim, don't apply it.
match = shim.isTriggeredByURLAndType(url, type);
if (match) {
// If the user has already opted in for this shim, all requests it covers
// should be allowed; no need for a shim anymore.
if (shim.hasUserOptedInAlready(topHost)) {
warn(
`Allowing tracking ${type} ${url} on tab ${tabId} frame ${frameId} due to opt-in`
);
shim.showOptInWarningOnce(tabId, new URL(originUrl).origin);
return undefined;
}
shimToApply = shim;
break;
}
}
if (shimToApply) {
// Note that sites may request the same shim twice, but because the requests
// may differ enough for some to fail (CSP/CORS/etc), we always let the request
// complete via local redirect. Shims should gracefully handle this as well.
const { target } = match;
const { bug, file, id, name, needsShimHelpers } = shimToApply;
const redirect = target || file;
warn(
`Shimming tracking ${type} ${url} on tab ${tabId} frame ${frameId} with ${redirect}`
);
const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
try {
// For scripts, we also set up any needed shim helpers.
if (type === "script" && needsShimHelpers?.length) {
await browser.tabs.executeScript(tabId, {
file: "/lib/shim_messaging_helper.js",
frameId,
runAt: "document_start",
});
const origin = new URL(originUrl).origin;
await browser.tabs.sendMessage(
tabId,
{ origin, shimId: id, needsShimHelpers, warning },
{ frameId }
);
shimToApply.setActiveOnTab(tabId);
} else {
await browser.tabs.executeScript(tabId, {
code: `console.warn(${JSON.stringify(warning)})`,
runAt: "document_start",
});
}
} catch (_) {}
// If any shims matched the request to replace it, then redirect to the local
// file bundled with SmartBlock, so the request never hits the network.
return { redirectUrl: browser.runtime.getURL(`shims/${redirect}`) };
}
// Sanity check: if no shims end up handling this request,
// yet it was meant to be blocked by ETP, then block it now.
if (unblocked) {
error(`unexpected: ${url} not shimmed on tab ${tabId} frame ${frameId}`);
return { cancel: true };
}
debug(`ignoring ${url} on tab ${tabId} frame ${frameId}`);
return undefined;
}
}
module.exports = Shims;