summaryrefslogtreecommitdiffstats
path: root/browser/extensions/webcompat/lib/shims.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/webcompat/lib/shims.js')
-rw-r--r--browser/extensions/webcompat/lib/shims.js415
1 files changed, 415 insertions, 0 deletions
diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js
new file mode 100644
index 0000000000..638411007b
--- /dev/null
+++ b/browser/extensions/webcompat/lib/shims.js
@@ -0,0 +1,415 @@
+/* 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 */
+
+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) !== "beta_or_release") {
+ console.debug.apply(this, arguments);
+ }
+};
+let error = async function() {
+ if ((await releaseBranchPromise) !== "beta_or_release") {
+ console.error.apply(this, arguments);
+ }
+};
+let warn = async function() {
+ if ((await releaseBranchPromise) !== "beta_or_release") {
+ 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.matches = 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;
+
+ const pref = `disabled_shims.${this.id}`;
+
+ 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).then(value => {
+ this._disabledPrefValue = value;
+ }),
+ platformPromise.then(platform => {
+ this._disabledByPlatform =
+ this.platform !== "all" && this.platform !== platform;
+ return platform;
+ }),
+ releaseBranchPromise.then(branch => {
+ this._disabledByReleaseBranch =
+ this.branches && !this.branches.includes(branch);
+ return branch;
+ }),
+ ]).then(([_, platform, branch]) => {
+ 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();
+ }
+
+ _allowRequestsInETP() {
+ return browser.trackingProtection.allow(this.id, this.matches, {
+ hosts: this.hosts,
+ notHosts: this.notHosts,
+ });
+ }
+
+ _revokeRequestsInETP() {
+ return browser.trackingProtection.revoke(this.id);
+ }
+
+ meantForHost(host) {
+ const { hosts, notHosts } = this;
+ if (hosts || notHosts) {
+ if (
+ (notHosts && notHosts.includes(host)) ||
+ (hosts && !hosts.includes(host))
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ isTriggeredByURL(url) {
+ if (!this.matches) {
+ return false;
+ }
+
+ if (!this._matcher) {
+ this._matcher = browser.matchPatterns.getMatcher(this.matches);
+ }
+
+ return this._matcher.matches(url);
+ }
+
+ async onUserOptIn(host) {
+ const { unblocksOnOptIn } = this;
+ if (unblocksOnOptIn) {
+ await browser.trackingProtection.allow(this.id, unblocksOnOptIn, {
+ hosts: [host],
+ });
+ }
+
+ this._hostOptIns.add(host);
+ }
+
+ hasUserOptedInAlready(host) {
+ return this._hostOptIns.has(host);
+ }
+}
+
+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 allShimPatterns = new Set();
+ for (const { matches } of this.shims.values()) {
+ for (const matchPattern of matches) {
+ allShimPatterns.add(matchPattern);
+ }
+ }
+
+ if (!allShimPatterns.size) {
+ debug("Skipping shims; none enabled");
+ return;
+ }
+
+ const urls = [...allShimPatterns];
+ debug("Shimming these match patterns", urls);
+
+ browser.webRequest.onBeforeRequest.addListener(
+ this._ensureShimForRequestOnTab.bind(this),
+ { urls, types: ["script"] },
+ ["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 } = 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 shim.options;
+ } else if (message === "optIn") {
+ try {
+ await shim.onUserOptIn(new URL(url).hostname);
+ warn("** User opted in on tab ", id, "for", shimId);
+ } catch (err) {
+ console.error(err);
+ throw new Error("error");
+ }
+ }
+
+ return undefined;
+ }
+
+ 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, 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 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 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)) {
+ return undefined;
+ }
+
+ // If this URL isn't meant for this shim, don't apply it.
+ if (!shim.isTriggeredByURL(url)) {
+ continue;
+ }
+
+ 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 re-run the
+ // shim JS just in case. Shims should gracefully handle this as well.
+ const { bug, file, id, name, needsShimHelpers } = shimToApply;
+ warn("Shimming", name, "on tabId", tabId, "frameId", frameId);
+
+ const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+
+ try {
+ if (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 }
+ );
+ } else {
+ await browser.tabs.executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ frameId,
+ runAt: "document_start",
+ });
+ }
+ } catch (_) {}
+
+ // If any shims matched the script to replace it, then let the original
+ // request complete without ever hitting the network, with a blank script.
+ return { redirectUrl: browser.runtime.getURL(`shims/${file}`) };
+ }
+
+ // Sanity check: if no shims are over-riding a given URL and it was meant to
+ // be blocked by ETP, then block it.
+ if (unblocked) {
+ error("unexpected:", url, "was not shimmed, and had to be re-blocked");
+ return { cancel: true };
+ }
+
+ debug("allowing", url);
+ return undefined;
+ }
+}
+
+module.exports = Shims;