summaryrefslogtreecommitdiffstats
path: root/browser/extensions/webcompat/lib
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions/webcompat/lib')
-rw-r--r--browser/extensions/webcompat/lib/about_compat_broker.js141
-rw-r--r--browser/extensions/webcompat/lib/custom_functions.js109
-rw-r--r--browser/extensions/webcompat/lib/injections.js165
-rw-r--r--browser/extensions/webcompat/lib/intervention_helpers.js233
-rw-r--r--browser/extensions/webcompat/lib/messaging_helper.js36
-rw-r--r--browser/extensions/webcompat/lib/module_shim.js24
-rw-r--r--browser/extensions/webcompat/lib/requestStorageAccess_helper.js30
-rw-r--r--browser/extensions/webcompat/lib/shim_messaging_helper.js65
-rw-r--r--browser/extensions/webcompat/lib/shims.js1045
-rw-r--r--browser/extensions/webcompat/lib/ua_helpers.js79
-rw-r--r--browser/extensions/webcompat/lib/ua_overrides.js210
11 files changed, 2137 insertions, 0 deletions
diff --git a/browser/extensions/webcompat/lib/about_compat_broker.js b/browser/extensions/webcompat/lib/about_compat_broker.js
new file mode 100644
index 0000000000..56a95aa102
--- /dev/null
+++ b/browser/extensions/webcompat/lib/about_compat_broker.js
@@ -0,0 +1,141 @@
+/* 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, module, onMessageFromTab */
+
+class AboutCompatBroker {
+ constructor(bindings) {
+ this._injections = bindings.injections;
+ this._uaOverrides = bindings.uaOverrides;
+ this._shims = bindings.shims;
+
+ if (!this._injections && !this._uaOverrides && !this._shims) {
+ throw new Error("No interventions; about:compat broker is not needed");
+ }
+
+ this.portsToAboutCompatTabs = this.buildPorts();
+ this._injections?.bindAboutCompatBroker(this);
+ this._uaOverrides?.bindAboutCompatBroker(this);
+ this._shims?.bindAboutCompatBroker(this);
+ }
+
+ buildPorts() {
+ const ports = new Set();
+
+ browser.runtime.onConnect.addListener(port => {
+ ports.add(port);
+ port.onDisconnect.addListener(function() {
+ ports.delete(port);
+ });
+ });
+
+ async function broadcast(message) {
+ for (const port of ports) {
+ port.postMessage(message);
+ }
+ }
+
+ return { broadcast };
+ }
+
+ filterOverrides(overrides) {
+ return overrides
+ .filter(override => override.availableOnPlatform)
+ .map(override => {
+ const { id, active, bug, domain, hidden } = override;
+ return { id, active, bug, domain, hidden };
+ });
+ }
+
+ getInterventionById(id) {
+ for (const [type, things] of Object.entries({
+ overrides: this._uaOverrides?.getAvailableOverrides() || [],
+ interventions: this._injections?.getAvailableInjections() || [],
+ shims: this._shims?.getAvailableShims() || [],
+ })) {
+ for (const what of things) {
+ if (what.id === id) {
+ return { type, what };
+ }
+ }
+ }
+ return {};
+ }
+
+ bootup() {
+ onMessageFromTab(msg => {
+ switch (msg.command || msg) {
+ case "toggle": {
+ const id = msg.id;
+ const { type, what } = this.getInterventionById(id);
+ if (!what) {
+ return Promise.reject(
+ `No such override or intervention to toggle: ${id}`
+ );
+ }
+ const active = type === "shims" ? !what.disabledReason : what.active;
+ this.portsToAboutCompatTabs
+ .broadcast({ toggling: id, active })
+ .then(async () => {
+ switch (type) {
+ case "interventions": {
+ if (active) {
+ await this._injections?.disableInjection(what);
+ } else {
+ await this._injections?.enableInjection(what);
+ }
+ break;
+ }
+ case "overrides": {
+ if (active) {
+ await this._uaOverrides?.disableOverride(what);
+ } else {
+ await this._uaOverrides?.enableOverride(what);
+ }
+ break;
+ }
+ case "shims": {
+ if (active) {
+ await this._shims?.disableShimForSession(id);
+ } else {
+ await this._shims?.enableShimForSession(id);
+ }
+ // no need to broadcast the "toggled" signal for shims, as
+ // they send a shimsUpdated message themselves instead
+ return;
+ }
+ }
+ this.portsToAboutCompatTabs.broadcast({
+ toggled: id,
+ active: !active,
+ });
+ });
+ break;
+ }
+ case "getAllInterventions": {
+ return Promise.resolve({
+ overrides:
+ (this._uaOverrides?.isEnabled() &&
+ this.filterOverrides(
+ this._uaOverrides?.getAvailableOverrides()
+ )) ||
+ false,
+ interventions:
+ (this._injections?.isEnabled() &&
+ this.filterOverrides(
+ this._injections?.getAvailableInjections()
+ )) ||
+ false,
+ shims: this._shims?.getAvailableShims() || false,
+ });
+ }
+ }
+ return undefined;
+ });
+ }
+}
+
+module.exports = AboutCompatBroker;
diff --git a/browser/extensions/webcompat/lib/custom_functions.js b/browser/extensions/webcompat/lib/custom_functions.js
new file mode 100644
index 0000000000..97603e0424
--- /dev/null
+++ b/browser/extensions/webcompat/lib/custom_functions.js
@@ -0,0 +1,109 @@
+/* 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 */
+
+const replaceStringInRequest = (
+ requestId,
+ inString,
+ outString,
+ inEncoding = "utf-8"
+) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ const decoder = new TextDecoder(inEncoding);
+ const encoder = new TextEncoder();
+ const RE = new RegExp(inString, "g");
+ const carryoverLength = inString.length;
+ let carryover = "";
+
+ filter.ondata = event => {
+ const replaced = (
+ carryover + decoder.decode(event.data, { stream: true })
+ ).replace(RE, outString);
+ filter.write(encoder.encode(replaced.slice(0, -carryoverLength)));
+ carryover = replaced.slice(-carryoverLength);
+ };
+
+ filter.onstop = event => {
+ if (carryover.length) {
+ filter.write(encoder.encode(carryover));
+ }
+ filter.close();
+ };
+};
+
+const CUSTOM_FUNCTIONS = {
+ detectSwipeFix: injection => {
+ const { urls, types } = injection.data;
+ const listener = (injection.data.listener = ({ requestId }) => {
+ replaceStringInRequest(
+ requestId,
+ "preventDefault:true",
+ "preventDefault:false"
+ );
+ return {};
+ });
+ browser.webRequest.onBeforeRequest.addListener(listener, { urls, types }, [
+ "blocking",
+ ]);
+ },
+ detectSwipeFixDisable: injection => {
+ const { listener } = injection.data;
+ browser.webRequest.onBeforeRequest.removeListener(listener);
+ delete injection.data.listener;
+ },
+ noSniffFix: injection => {
+ const { urls, contentType } = injection.data;
+ const listener = (injection.data.listener = e => {
+ e.responseHeaders.push(contentType);
+ return { responseHeaders: e.responseHeaders };
+ });
+
+ browser.webRequest.onHeadersReceived.addListener(listener, { urls }, [
+ "blocking",
+ "responseHeaders",
+ ]);
+ },
+ noSniffFixDisable: injection => {
+ const { listener } = injection.data;
+ browser.webRequest.onHeadersReceived.removeListener(listener);
+ delete injection.data.listener;
+ },
+ runScriptBeforeRequest: injection => {
+ const { bug, message, request, script, types } = injection;
+ const warning = `${message} See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+
+ const listener = (injection.listener = e => {
+ const { tabId, frameId } = e;
+ return browser.tabs
+ .executeScript(tabId, {
+ file: script,
+ frameId,
+ runAt: "document_start",
+ })
+ .then(() => {
+ browser.tabs.executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ });
+ })
+ .catch(_ => {});
+ });
+
+ browser.webRequest.onBeforeRequest.addListener(
+ listener,
+ { urls: request, types: types || ["script"] },
+ ["blocking"]
+ );
+ },
+ runScriptBeforeRequestDisable: injection => {
+ const { listener } = injection;
+ browser.webRequest.onBeforeRequest.removeListener(listener);
+ delete injection.data.listener;
+ },
+};
+
+module.exports = CUSTOM_FUNCTIONS;
diff --git a/browser/extensions/webcompat/lib/injections.js b/browser/extensions/webcompat/lib/injections.js
new file mode 100644
index 0000000000..8760f551c7
--- /dev/null
+++ b/browser/extensions/webcompat/lib/injections.js
@@ -0,0 +1,165 @@
+/* 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 */
+
+class Injections {
+ constructor(availableInjections, customFunctions) {
+ this.INJECTION_PREF = "perform_injections";
+
+ this._injectionsEnabled = true;
+
+ this._availableInjections = availableInjections;
+ this._activeInjections = new Map();
+ this._customFunctions = customFunctions;
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ bootup() {
+ browser.aboutConfigPrefs.onPrefChange.addListener(() => {
+ this.checkInjectionPref();
+ }, this.INJECTION_PREF);
+ this.checkInjectionPref();
+ }
+
+ checkInjectionPref() {
+ browser.aboutConfigPrefs.getPref(this.INJECTION_PREF).then(value => {
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setPref(this.INJECTION_PREF, true);
+ } else if (value === false) {
+ this.unregisterContentScripts();
+ } else {
+ this.registerContentScripts();
+ }
+ });
+ }
+
+ getAvailableInjections() {
+ return this._availableInjections;
+ }
+
+ isEnabled() {
+ return this._injectionsEnabled;
+ }
+
+ async registerContentScripts() {
+ const platformInfo = await browser.runtime.getPlatformInfo();
+ const platformMatches = [
+ "all",
+ platformInfo.os,
+ platformInfo.os == "android" ? "android" : "desktop",
+ ];
+ for (const injection of this._availableInjections) {
+ if (platformMatches.includes(injection.platform)) {
+ injection.availableOnPlatform = true;
+ await this.enableInjection(injection);
+ }
+ }
+
+ this._injectionsEnabled = true;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ interventionsChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableInjections
+ ),
+ });
+ }
+
+ assignContentScriptDefaults(contentScripts) {
+ let finalConfig = Object.assign({}, contentScripts);
+
+ if (!finalConfig.runAt) {
+ finalConfig.runAt = "document_start";
+ }
+
+ return finalConfig;
+ }
+
+ async enableInjection(injection) {
+ if (injection.active) {
+ return undefined;
+ }
+
+ if (injection.customFunc) {
+ return this.enableCustomInjection(injection);
+ }
+
+ return this.enableContentScripts(injection);
+ }
+
+ enableCustomInjection(injection) {
+ if (injection.customFunc in this._customFunctions) {
+ this._customFunctions[injection.customFunc](injection);
+ injection.active = true;
+ } else {
+ console.error(
+ `Provided function ${injection.customFunc} wasn't found in functions list`
+ );
+ }
+ }
+
+ async enableContentScripts(injection) {
+ try {
+ const handle = await browser.contentScripts.register(
+ this.assignContentScriptDefaults(injection.contentScripts)
+ );
+ this._activeInjections.set(injection, handle);
+ injection.active = true;
+ } catch (ex) {
+ console.error(
+ "Registering WebCompat GoFaster content scripts failed: ",
+ ex
+ );
+ }
+ }
+
+ unregisterContentScripts() {
+ for (const injection of this._availableInjections) {
+ this.disableInjection(injection);
+ }
+
+ this._injectionsEnabled = false;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ interventionsChanged: false,
+ });
+ }
+
+ async disableInjection(injection) {
+ if (!injection.active) {
+ return undefined;
+ }
+
+ if (injection.customFunc) {
+ return this.disableCustomInjections(injection);
+ }
+
+ return this.disableContentScripts(injection);
+ }
+
+ disableCustomInjections(injection) {
+ const disableFunc = injection.customFunc + "Disable";
+
+ if (disableFunc in this._customFunctions) {
+ this._customFunctions[disableFunc](injection);
+ injection.active = false;
+ } else {
+ console.error(
+ `Provided function ${disableFunc} for disabling injection wasn't found in functions list`
+ );
+ }
+ }
+
+ async disableContentScripts(injection) {
+ const contentScript = this._activeInjections.get(injection);
+ await contentScript.unregister();
+ this._activeInjections.delete(injection);
+ injection.active = false;
+ }
+}
+
+module.exports = Injections;
diff --git a/browser/extensions/webcompat/lib/intervention_helpers.js b/browser/extensions/webcompat/lib/intervention_helpers.js
new file mode 100644
index 0000000000..16ea6572f2
--- /dev/null
+++ b/browser/extensions/webcompat/lib/intervention_helpers.js
@@ -0,0 +1,233 @@
+/* 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 module */
+
+const GOOGLE_TLDS = [
+ "com",
+ "ac",
+ "ad",
+ "ae",
+ "com.af",
+ "com.ag",
+ "com.ai",
+ "al",
+ "am",
+ "co.ao",
+ "com.ar",
+ "as",
+ "at",
+ "com.au",
+ "az",
+ "ba",
+ "com.bd",
+ "be",
+ "bf",
+ "bg",
+ "com.bh",
+ "bi",
+ "bj",
+ "com.bn",
+ "com.bo",
+ "com.br",
+ "bs",
+ "bt",
+ "co.bw",
+ "by",
+ "com.bz",
+ "ca",
+ "com.kh",
+ "cc",
+ "cd",
+ "cf",
+ "cat",
+ "cg",
+ "ch",
+ "ci",
+ "co.ck",
+ "cl",
+ "cm",
+ "cn",
+ "com.co",
+ "co.cr",
+ "com.cu",
+ "cv",
+ "com.cy",
+ "cz",
+ "de",
+ "dj",
+ "dk",
+ "dm",
+ "com.do",
+ "dz",
+ "com.ec",
+ "ee",
+ "com.eg",
+ "es",
+ "com.et",
+ "fi",
+ "com.fj",
+ "fm",
+ "fr",
+ "ga",
+ "ge",
+ "gf",
+ "gg",
+ "com.gh",
+ "com.gi",
+ "gl",
+ "gm",
+ "gp",
+ "gr",
+ "com.gt",
+ "gy",
+ "com.hk",
+ "hn",
+ "hr",
+ "ht",
+ "hu",
+ "co.id",
+ "iq",
+ "ie",
+ "co.il",
+ "im",
+ "co.in",
+ "io",
+ "is",
+ "it",
+ "je",
+ "com.jm",
+ "jo",
+ "co.jp",
+ "co.ke",
+ "ki",
+ "kg",
+ "co.kr",
+ "com.kw",
+ "kz",
+ "la",
+ "com.lb",
+ "com.lc",
+ "li",
+ "lk",
+ "co.ls",
+ "lt",
+ "lu",
+ "lv",
+ "com.ly",
+ "co.ma",
+ "md",
+ "me",
+ "mg",
+ "mk",
+ "ml",
+ "com.mm",
+ "mn",
+ "ms",
+ "com.mt",
+ "mu",
+ "mv",
+ "mw",
+ "com.mx",
+ "com.my",
+ "co.mz",
+ "com.na",
+ "ne",
+ "com.nf",
+ "com.ng",
+ "com.ni",
+ "nl",
+ "no",
+ "com.np",
+ "nr",
+ "nu",
+ "co.nz",
+ "com.om",
+ "com.pk",
+ "com.pa",
+ "com.pe",
+ "com.ph",
+ "pl",
+ "com.pg",
+ "pn",
+ "com.pr",
+ "ps",
+ "pt",
+ "com.py",
+ "com.qa",
+ "ro",
+ "rs",
+ "ru",
+ "rw",
+ "com.sa",
+ "com.sb",
+ "sc",
+ "se",
+ "com.sg",
+ "sh",
+ "si",
+ "sk",
+ "com.sl",
+ "sn",
+ "sm",
+ "so",
+ "st",
+ "sr",
+ "com.sv",
+ "td",
+ "tg",
+ "co.th",
+ "com.tj",
+ "tk",
+ "tl",
+ "tm",
+ "to",
+ "tn",
+ "com.tr",
+ "tt",
+ "com.tw",
+ "co.tz",
+ "com.ua",
+ "co.ug",
+ "co.uk",
+ "com",
+ "com.uy",
+ "co.uz",
+ "com.vc",
+ "co.ve",
+ "vg",
+ "co.vi",
+ "com.vn",
+ "vu",
+ "ws",
+ "co.za",
+ "co.zm",
+ "co.zw",
+];
+
+var InterventionHelpers = {
+ /**
+ * Useful helper to generate a list of domains with a fixed base domain and
+ * multiple country-TLDs or other cases with various TLDs.
+ *
+ * Example:
+ * matchPatternsForTLDs("*://mozilla.", "/*", ["com", "org"])
+ * => ["*://mozilla.com/*", "*://mozilla.org/*"]
+ */
+ matchPatternsForTLDs(base, suffix, tlds) {
+ return tlds.map(tld => base + tld + suffix);
+ },
+
+ /**
+ * A modified version of matchPatternsForTLDs that always returns the match
+ * list for all known Google country TLDs.
+ */
+ matchPatternsForGoogle(base, suffix = "/*") {
+ return InterventionHelpers.matchPatternsForTLDs(base, suffix, GOOGLE_TLDS);
+ },
+};
+
+module.exports = InterventionHelpers;
diff --git a/browser/extensions/webcompat/lib/messaging_helper.js b/browser/extensions/webcompat/lib/messaging_helper.js
new file mode 100644
index 0000000000..793fa03139
--- /dev/null
+++ b/browser/extensions/webcompat/lib/messaging_helper.js
@@ -0,0 +1,36 @@
+/* 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 */
+
+// By default, only the first handler for browser.runtime.onMessage which
+// returns a value will get to return one. As such, we need to let them all
+// receive the message, and all have a chance to return a response (with the
+// first non-undefined result being the one that is ultimately returned).
+// This way, about:compat and the shims library can both get a chance to
+// process a message, and just return undefined if they wish to ignore it.
+
+const onMessageFromTab = (function() {
+ const handlers = new Set();
+
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ const promises = [...handlers.values()].map(fn => fn(msg, sender));
+ return Promise.allSettled(promises).then(results => {
+ for (const { reason, value } of results) {
+ if (reason) {
+ console.error(reason);
+ } else if (value !== undefined) {
+ return value;
+ }
+ }
+ return undefined;
+ });
+ });
+
+ return function(handler) {
+ handlers.add(handler);
+ };
+})();
diff --git a/browser/extensions/webcompat/lib/module_shim.js b/browser/extensions/webcompat/lib/module_shim.js
new file mode 100644
index 0000000000..2fd39fdbbd
--- /dev/null
+++ b/browser/extensions/webcompat/lib/module_shim.js
@@ -0,0 +1,24 @@
+/* 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";
+
+/**
+ * We cannot yet use proper JS modules within webextensions, as support for them
+ * is highly experimental and highly instable. So we end up just including all
+ * the JS files we need as separate background scripts, and since they all are
+ * executed within the same context, this works for our in-browser deployment.
+ *
+ * However, this code is tracked outside of mozilla-central, and we work on
+ * shipping this code in other products, like android-components as well.
+ * Because of that, we have automated tests running within that repository. To
+ * make our lives easier, we add `module.exports` statements to the JS source
+ * files, so we can easily import their contents into our NodeJS-based test
+ * suite.
+ *
+ * This works fine, but obviously, `module` is not defined when running
+ * in-browser. So let's use this empty object as a shim, so we don't run into
+ * runtime exceptions because of that.
+ */
+var module = {};
diff --git a/browser/extensions/webcompat/lib/requestStorageAccess_helper.js b/browser/extensions/webcompat/lib/requestStorageAccess_helper.js
new file mode 100644
index 0000000000..032225bb78
--- /dev/null
+++ b/browser/extensions/webcompat/lib/requestStorageAccess_helper.js
@@ -0,0 +1,30 @@
+/* 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/. */
+
+/* globals browser */
+
+// Helper for calling the internal requestStorageAccessForOrigin method. The
+// method is called on the first-party document for the third-party which needs
+// first-party storage access.
+browser.runtime.onMessage.addListener(request => {
+ let { requestStorageAccessOrigin, warning } = request;
+ if (!requestStorageAccessOrigin) {
+ return false;
+ }
+
+ // Log a warning to the web console, informing about the shim.
+ console.warn(warning);
+
+ // Call the internal storage access API. Passing false means we don't require
+ // user activation, but will always show the storage access prompt. The user
+ // has to explicitly allow storage access.
+ return document
+ .requestStorageAccessForOrigin(requestStorageAccessOrigin, false)
+ .then(() => {
+ return { success: true };
+ })
+ .catch(() => {
+ return { success: false };
+ });
+});
diff --git a/browser/extensions/webcompat/lib/shim_messaging_helper.js b/browser/extensions/webcompat/lib/shim_messaging_helper.js
new file mode 100644
index 0000000000..ee109713a5
--- /dev/null
+++ b/browser/extensions/webcompat/lib/shim_messaging_helper.js
@@ -0,0 +1,65 @@
+/* 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 */
+
+if (!window.Shims) {
+ window.Shims = new Map();
+}
+
+if (!window.ShimsHelperReady) {
+ window.ShimsHelperReady = true;
+
+ browser.runtime.onMessage.addListener(details => {
+ const { shimId, warning } = details;
+ if (!shimId) {
+ return;
+ }
+ window.Shims.set(shimId, details);
+ if (warning) {
+ console.warn(warning);
+ }
+ });
+
+ async function handleMessage(port, shimId, messageId, message) {
+ let response;
+ const shim = window.Shims.get(shimId);
+ if (shim) {
+ const { needsShimHelpers, origin } = shim;
+ if (origin === location.origin) {
+ if (needsShimHelpers?.includes(message)) {
+ const msg = { shimId, message };
+ try {
+ response = await browser.runtime.sendMessage(msg);
+ } catch (_) {}
+ }
+ }
+ }
+ port.postMessage({ messageId, response });
+ }
+
+ window.addEventListener(
+ "ShimConnects",
+ e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { port, pendingMessages, shimId } = e.detail;
+ const shim = window.Shims.get(shimId);
+ if (!shim) {
+ return;
+ }
+ port.onmessage = ({ data }) => {
+ handleMessage(port, shimId, data.messageId, data.message);
+ };
+ for (const [messageId, message] of pendingMessages) {
+ handleMessage(port, shimId, messageId, message);
+ }
+ },
+ true
+ );
+
+ window.dispatchEvent(new CustomEvent("ShimHelperReady"));
+}
diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js
new file mode 100644
index 0000000000..fcba62f4ce
--- /dev/null
+++ b/browser/extensions/webcompat/lib/shims.js
@@ -0,0 +1,1045 @@
+/* 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, manager) {
+ this.manager = manager;
+
+ const { contentScripts, matches, unblocksOnOptIn } = opts;
+
+ this.branches = opts.branches;
+ this.bug = opts.bug;
+ this.isGoogleTrendsDFPIFix = opts.custom == "google-trends-dfpi-fix";
+ this.file = opts.file;
+ this.hiddenInAboutCompat = opts.hiddenInAboutCompat;
+ 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.onlyIfDFPIActive = opts.onlyIfDFPIActive;
+ this.onlyIfPrivateBrowsing = opts.onlyIfPrivateBrowsing;
+ this._options = opts.options || {};
+ this.needsShimHelpers = opts.needsShimHelpers;
+ this.platform = opts.platform || "all";
+ this.runFirst = opts.runFirst;
+ this.unblocksOnOptIn = unblocksOnOptIn;
+ this.requestStorageAccessForRedirect = opts.requestStorageAccessForRedirect;
+
+ this._hostOptIns = new Set();
+
+ this._disabledByConfig = opts.disabled;
+ this._disabledGlobally = false;
+ this._disabledForSession = false;
+ this._disabledByPlatform = false;
+ this._disabledByReleaseBranch = false;
+
+ this._activeOnTabs = new Set();
+ this._showedOptInOnTabs = new Set();
+
+ const pref = `disabled_shims.${this.id}`;
+
+ this.redirectsRequests = !!this.file && matches?.length;
+
+ this._contentScriptRegistrations = [];
+ this.contentScripts = contentScripts || [];
+ for (const script of this.contentScripts) {
+ if (typeof script.css === "string") {
+ script.css = [{ file: `/shims/${script.css}` }];
+ }
+ if (typeof script.js === "string") {
+ script.js = [{ file: `/shims/${script.js}` }];
+ }
+ }
+
+ for (const match of matches || []) {
+ if (!match.types) {
+ this.matches.push({ patterns: [match], types: ["script"] });
+ } else {
+ this.matches.push(match);
+ }
+ if (match.target) {
+ this.redirectsRequests = true;
+ }
+ }
+
+ 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 || this._disabledForSession) {
+ return false;
+ }
+
+ if (this._disabledPrefValue !== undefined) {
+ return !this._disabledPrefValue;
+ }
+
+ return (
+ !this._disabledByConfig &&
+ !this._disabledByPlatform &&
+ !this._disabledByReleaseBranch
+ );
+ }
+
+ get disabledReason() {
+ if (this._disabledGlobally) {
+ return "globalPref";
+ }
+
+ if (this._disabledForSession) {
+ return "session";
+ }
+
+ if (this._disabledPrefValue !== undefined) {
+ if (this._disabledPrefValue === true) {
+ return "pref";
+ }
+ return false;
+ }
+
+ if (this._disabledByConfig) {
+ return "config";
+ }
+
+ if (this._disabledByPlatform) {
+ return "platform";
+ }
+
+ if (this._disabledByReleaseBranch) {
+ return "releaseBranch";
+ }
+
+ return false;
+ }
+
+ onAllShimsEnabled() {
+ const wasEnabled = this.enabled;
+ this._disabledGlobally = false;
+ if (!wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ onAllShimsDisabled() {
+ const wasEnabled = this.enabled;
+ this._disabledGlobally = true;
+ if (wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ enableForSession() {
+ const wasEnabled = this.enabled;
+ this._disabledForSession = false;
+ if (!wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ disableForSession() {
+ const wasEnabled = this.enabled;
+ this._disabledForSession = true;
+ if (wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ async _onEnabledStateChanged() {
+ this.manager?.onShimStateChanged(this.id);
+ if (!this.enabled) {
+ await this._unregisterContentScripts();
+ return this._revokeRequestsInETP();
+ }
+ await this._registerContentScripts();
+ return this._allowRequestsInETP();
+ }
+
+ async _registerContentScripts() {
+ if (
+ this.contentScripts.length &&
+ !this._contentScriptRegistrations.length
+ ) {
+ const matches = [];
+ for (const options of this.contentScripts) {
+ matches.push(options.matches);
+ const reg = await browser.contentScripts.register(options);
+ this._contentScriptRegistrations.push(reg);
+ }
+ const urls = Array.from(new Set(matches.flat()));
+ debug("Enabling content scripts for these URLs:", urls);
+ }
+ }
+
+ async _unregisterContentScripts() {
+ for (const registration of this._contentScriptRegistrations) {
+ registration.unregister();
+ }
+ this._contentScriptRegistrations = [];
+ }
+
+ 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();
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ getShimInfoForAboutCompat(shim) {
+ const { bug, disabledReason, hiddenInAboutCompat, id, name } = shim;
+ const type = "smartblock";
+ return { bug, disabledReason, hidden: hiddenInAboutCompat, id, name, type };
+ }
+
+ disableShimForSession(id) {
+ const shim = this.shims.get(id);
+ shim?.disableForSession();
+ }
+
+ enableShimForSession(id) {
+ const shim = this.shims.get(id);
+ shim?.enableForSession();
+ }
+
+ onShimStateChanged(id) {
+ if (!this._aboutCompatBroker) {
+ return;
+ }
+
+ const shim = this.shims.get(id);
+ if (!shim) {
+ return;
+ }
+
+ const shimsChanged = [this.getShimInfoForAboutCompat(shim)];
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ shimsChanged });
+ }
+
+ getAvailableShims() {
+ const shims = Array.from(this.shims.values()).map(
+ this.getShimInfoForAboutCompat
+ );
+ shims.sort((a, b) => a.name.localeCompare(b.name));
+ return shims;
+ }
+
+ _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, this));
+ }
+ }
+
+ // Register onBeforeRequest listener which handles storage access requests
+ // on matching redirects.
+ let redirectTargetUrls = Array.from(shims.values())
+ .filter(shim => shim.requestStorageAccessForRedirect)
+ .flatMap(shim => shim.requestStorageAccessForRedirect)
+ .map(([, dstUrl]) => dstUrl);
+
+ // Unique target urls.
+ redirectTargetUrls = Array.from(new Set(redirectTargetUrls));
+
+ if (redirectTargetUrls.length) {
+ debug("Registering redirect listener for requestStorageAccess helper", {
+ redirectTargetUrls,
+ });
+ browser.webRequest.onBeforeRequest.addListener(
+ this._onRequestStorageAccessRedirect.bind(this),
+ { urls: redirectTargetUrls, types: ["main_frame"] },
+ ["blocking"]
+ );
+ }
+
+ function addTypePatterns(type, patterns, set) {
+ if (!set.has(type)) {
+ set.set(type, { patterns: new Set() });
+ }
+ const allSet = set.get(type).patterns;
+ for (const pattern of patterns) {
+ allSet.add(pattern);
+ }
+ }
+
+ const allMatchTypePatterns = new Map();
+ const allHeaderChangingMatchTypePatterns = new Map();
+ const allLogos = [];
+ for (const shim of this.shims.values()) {
+ const { logos, matches } = shim;
+ allLogos.push(...logos);
+ for (const { patterns, target, types } of matches || []) {
+ for (const type of types) {
+ if (shim.isGoogleTrendsDFPIFix) {
+ addTypePatterns(type, patterns, allHeaderChangingMatchTypePatterns);
+ }
+ if (target || shim.file || shim.runFirst) {
+ addTypePatterns(type, patterns, allMatchTypePatterns);
+ }
+ }
+ }
+ }
+
+ 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 (allHeaderChangingMatchTypePatterns) {
+ for (const [
+ type,
+ { patterns },
+ ] of allHeaderChangingMatchTypePatterns.entries()) {
+ const urls = Array.from(patterns);
+ debug("Shimming these", type, "URLs:", urls);
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ this._onBeforeSendHeaders.bind(this),
+ { urls, types: [type] },
+ ["blocking", "requestHeaders"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ this._onHeadersReceived.bind(this),
+ { urls, types: [type] },
+ ["blocking", "responseHeaders"]
+ );
+ }
+ }
+
+ 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.onAllShimsEnabled();
+ } else {
+ shim.onAllShimsDisabled();
+ }
+ }
+ }
+
+ async _onRequestStorageAccessRedirect({
+ originUrl: srcUrl,
+ url: dstUrl,
+ tabId,
+ }) {
+ debug("Detected redirect", { srcUrl, dstUrl, tabId });
+
+ // Check if a shim needs to request storage access for this redirect. This
+ // handler is called when the *source url* matches a shims redirect pattern,
+ // but we still need to check if the *destination url* matches.
+ const matchingShims = Array.from(this.shims.values()).filter(shim => {
+ const { enabled, requestStorageAccessForRedirect } = shim;
+
+ if (!enabled || !requestStorageAccessForRedirect) {
+ return false;
+ }
+
+ return requestStorageAccessForRedirect.some(
+ ([srcPattern, dstPattern]) =>
+ browser.matchPatterns.getMatcher([srcPattern]).matches(srcUrl) &&
+ browser.matchPatterns.getMatcher([dstPattern]).matches(dstUrl)
+ );
+ });
+
+ // For each matching shim, find out if its enabled in regard to dFPI state.
+ const bugNumbers = new Set();
+ let isDFPIActive = null;
+ await Promise.all(
+ matchingShims.map(async shim => {
+ if (shim.onlyIfDFPIActive) {
+ // Only get the dFPI state for the first shim which requires it.
+ if (isDFPIActive === null) {
+ const tabIsPB = (await browser.tabs.get(tabId)).incognito;
+ isDFPIActive = await browser.trackingProtection.isDFPIActive(
+ tabIsPB
+ );
+ }
+ if (!isDFPIActive) {
+ return;
+ }
+ }
+ bugNumbers.add(shim.bug);
+ })
+ );
+
+ // If there is no shim which needs storage access for this redirect src/dst
+ // pair, resume it.
+ if (!bugNumbers.size) {
+ return;
+ }
+
+ // Inject the helper to call requestStorageAccessForOrigin on the document.
+ await browser.tabs.executeScript(tabId, {
+ file: "/lib/requestStorageAccess_helper.js",
+ runAt: "document_start",
+ });
+
+ const bugUrls = Array.from(bugNumbers)
+ .map(bugNo => `https://bugzilla.mozilla.org/show_bug.cgi?id=${bugNo}`)
+ .join(", ");
+ const warning = `Firefox calls the Storage Access API for ${dstUrl} on behalf of ${srcUrl}. See the following bugs for details: ${bugUrls}`;
+
+ // Request storage access for the origin of the destination url of the
+ // redirect.
+ const { origin: requestStorageAccessOrigin } = new URL(dstUrl);
+
+ // Wait for the requestStorageAccess request to finish before resuming the
+ // redirect.
+ const { success } = await browser.tabs.sendMessage(tabId, {
+ requestStorageAccessOrigin,
+ warning,
+ });
+ debug("requestStorageAccess callback", {
+ success,
+ requestStorageAccessOrigin,
+ srcUrl,
+ dstUrl,
+ bugNumbers,
+ });
+ }
+
+ 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.onlyIfDFPIActive) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!(await browser.trackingProtection.isDFPIActive(isPB))) {
+ continue;
+ }
+ }
+
+ if (!shim.logos.includes(logo)) {
+ continue;
+ }
+
+ if (shim.isActiveOnTab(tabId)) {
+ return { redirectUrl: browser.runtime.getURL(`shims/${logo}`) };
+ }
+ }
+
+ return { cancel: true };
+ }
+
+ async _onHeadersReceived(details) {
+ await this._haveCheckedEnabledPref;
+
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled) {
+ continue;
+ }
+
+ if (shim.onlyIfDFPIActive) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!(await browser.trackingProtection.isDFPIActive(isPB))) {
+ continue;
+ }
+ }
+
+ if (shim.isGoogleTrendsDFPIFix) {
+ if (shim.GoogleNidCookieToUse) {
+ continue;
+ }
+
+ for (const header of details.responseHeaders) {
+ if (header.name == "set-cookie") {
+ shim.GoogleNidCookieToUse = header.value;
+ return { redirectUrl: details.url };
+ }
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ async _onBeforeSendHeaders(details) {
+ await this._haveCheckedEnabledPref;
+
+ const { frameId, requestHeaders, tabId } = details;
+
+ if (!this.enabled) {
+ return { requestHeaders };
+ }
+
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled) {
+ continue;
+ }
+
+ if (shim.isGoogleTrendsDFPIFix) {
+ const value = shim.GoogleNidCookieToUse;
+
+ if (!value) {
+ continue;
+ }
+
+ let found;
+ for (let header of requestHeaders) {
+ if (header.name.toLowerCase() === "cookie") {
+ header.value = value;
+ found = true;
+ }
+ }
+ if (!found) {
+ requestHeaders.push({ name: "Cookie", value });
+ }
+
+ browser.tabs
+ .get(tabId)
+ .then(({ url }) => {
+ debug(
+ `Google Trends dFPI fix used on tab ${tabId} frame ${frameId} (${url})`
+ );
+ })
+ .catch(() => {});
+
+ const warning = `Working around Google Trends tracking protection breakage. See https://bugzilla.mozilla.org/show_bug.cgi?id=${shim.bug} for details.`;
+ browser.tabs
+ .executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ })
+ .catch(() => {});
+ }
+ }
+
+ return { requestHeaders };
+ }
+
+ 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 || (!shim.redirectsRequests && !shim.runFirst)) {
+ continue;
+ }
+
+ if (shim.onlyIfDFPIActive || shim.onlyIfPrivateBrowsing) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!isPB && shim.onlyIfPrivateBrowsing) {
+ continue;
+ }
+ if (
+ shim.onlyIfDFPIActive &&
+ !(await browser.trackingProtection.isDFPIActive(isPB))
+ ) {
+ 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 (!unblocked && match.onlyIfBlockedByETP) {
+ 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)) {
+ 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;
+ }
+ }
+
+ let runFirst = false;
+
+ 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;
+ runFirst = shimToApply.runFirst;
+
+ const redirect = target || file;
+
+ warn(
+ `Shimming tracking ${type} ${url} on tab ${tabId} frame ${frameId} with ${redirect ||
+ runFirst}`
+ );
+
+ const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+
+ let needConsoleMessage = true;
+
+ if (runFirst) {
+ try {
+ await browser.tabs.executeScript(tabId, {
+ file: `/shims/${runFirst}`,
+ frameId,
+ runAt: "document_start",
+ });
+ } catch (_) {}
+ }
+
+ // For scripts, we also set up any needed shim helpers.
+ if (type === "script" && needsShimHelpers?.length) {
+ try {
+ 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 }
+ );
+ needConsoleMessage = false;
+ shimToApply.setActiveOnTab(tabId);
+ } catch (_) {}
+ }
+
+ if (needConsoleMessage) {
+ try {
+ await browser.tabs.executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ });
+ } catch (_) {}
+ }
+
+ if (!redirect.indexOf("http://") || !redirect.indexOf("https://")) {
+ return { redirectUrl: redirect };
+ }
+
+ // 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 };
+ }
+
+ if (!runFirst) {
+ debug(`ignoring ${url} on tab ${tabId} frame ${frameId}`);
+ }
+ return undefined;
+ }
+}
+
+module.exports = Shims;
diff --git a/browser/extensions/webcompat/lib/ua_helpers.js b/browser/extensions/webcompat/lib/ua_helpers.js
new file mode 100644
index 0000000000..5d09e7f31d
--- /dev/null
+++ b/browser/extensions/webcompat/lib/ua_helpers.js
@@ -0,0 +1,79 @@
+/* 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 exportFunction, module */
+
+var UAHelpers = {
+ _deviceAppropriateChromeUAs: {},
+ getDeviceAppropriateChromeUA(config = {}) {
+ const { version = "103.0.5060.71", androidDevice, desktopOS } = config;
+ const key = `${version}:${androidDevice}:${desktopOS}`;
+ if (!UAHelpers._deviceAppropriateChromeUAs[key]) {
+ const userAgent =
+ typeof navigator !== "undefined" ? navigator.userAgent : "";
+ const RunningFirefoxVersion = (userAgent.match(/Firefox\/([0-9.]+)/) || [
+ "",
+ "58.0",
+ ])[1];
+
+ if (userAgent.includes("Android")) {
+ const RunningAndroidVersion =
+ userAgent.match(/Android [0-9.]+/) || "Android 6.0";
+ if (androidDevice) {
+ UAHelpers._deviceAppropriateChromeUAs[
+ key
+ ] = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; ${androidDevice}) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Mobile Safari/537.36`;
+ } else {
+ const ChromePhoneUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 5 Build/MRA58N) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Mobile Safari/537.36`;
+ const ChromeTabletUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 7 Build/JSS15Q) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
+ const IsPhone = userAgent.includes("Mobile");
+ UAHelpers._deviceAppropriateChromeUAs[key] = IsPhone
+ ? ChromePhoneUA
+ : ChromeTabletUA;
+ }
+ } else {
+ let osSegment = "Windows NT 10.0; Win64; x64";
+ if (desktopOS === "macOS" || userAgent.includes("Macintosh")) {
+ osSegment = "Macintosh; Intel Mac OS X 10_15_7";
+ }
+ if (
+ desktopOS !== "nonLinux" &&
+ (desktopOS === "linux" || userAgent.includes("Linux"))
+ ) {
+ osSegment = "X11; Ubuntu; Linux x86_64";
+ }
+
+ UAHelpers._deviceAppropriateChromeUAs[
+ key
+ ] = `Mozilla/5.0 (${osSegment}) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
+ }
+ }
+ return UAHelpers._deviceAppropriateChromeUAs[key];
+ },
+ getPrefix(originalUA) {
+ return originalUA.substr(0, originalUA.indexOf(")") + 1);
+ },
+ overrideWithDeviceAppropriateChromeUA(config) {
+ const chromeUA = UAHelpers.getDeviceAppropriateChromeUA(config);
+ Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(() => chromeUA, window),
+ set: exportFunction(function() {}, window),
+ });
+ },
+ capVersionTo99(originalUA) {
+ const ver = originalUA.match(/Firefox\/(\d+\.\d+)/);
+ if (!ver || parseFloat(ver[1]) < 100) {
+ return originalUA;
+ }
+ return originalUA
+ .replace(`Firefox/${ver[1]}`, "Firefox/99.0")
+ .replace(`rv:${ver[1]}`, "rv:99.0");
+ },
+};
+
+if (typeof module !== "undefined") {
+ module.exports = UAHelpers;
+}
diff --git a/browser/extensions/webcompat/lib/ua_overrides.js b/browser/extensions/webcompat/lib/ua_overrides.js
new file mode 100644
index 0000000000..2426293f3f
--- /dev/null
+++ b/browser/extensions/webcompat/lib/ua_overrides.js
@@ -0,0 +1,210 @@
+/* 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 */
+
+class UAOverrides {
+ constructor(availableOverrides) {
+ this.OVERRIDE_PREF = "perform_ua_overrides";
+
+ this._overridesEnabled = true;
+
+ this._availableOverrides = availableOverrides;
+ this._activeListeners = new Map();
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ bootup() {
+ browser.aboutConfigPrefs.onPrefChange.addListener(() => {
+ this.checkOverridePref();
+ }, this.OVERRIDE_PREF);
+ this.checkOverridePref();
+ }
+
+ checkOverridePref() {
+ browser.aboutConfigPrefs.getPref(this.OVERRIDE_PREF).then(value => {
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setPref(this.OVERRIDE_PREF, true);
+ } else if (value === false) {
+ this.unregisterUAOverrides();
+ } else {
+ this.registerUAOverrides();
+ }
+ });
+ }
+
+ getAvailableOverrides() {
+ return this._availableOverrides;
+ }
+
+ isEnabled() {
+ return this._overridesEnabled;
+ }
+
+ enableOverride(override) {
+ if (override.active) {
+ return;
+ }
+
+ const { blocks, matches, uaTransformer } = override.config;
+ const listener = details => {
+ // Don't actually override the UA for an experiment if the user is not
+ // part of the experiment (unless they force-enabed the override).
+ if (
+ !override.config.experiment ||
+ override.permanentPrefEnabled === true
+ ) {
+ for (const header of details.requestHeaders) {
+ if (header.name.toLowerCase() === "user-agent") {
+ // Don't override the UA if we're on a mobile device that has the
+ // "Request Desktop Site" mode enabled. The UA for the desktop mode
+ // is set inside Gecko with a simple string replace, so we can use
+ // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28
+ let isMobileWithDesktopMode =
+ override.currentPlatform == "android" &&
+ header.value.includes("X11; Linux x86_64");
+
+ if (!isMobileWithDesktopMode) {
+ header.value = uaTransformer(header.value);
+ }
+ }
+ }
+ }
+ return { requestHeaders: details.requestHeaders };
+ };
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ listener,
+ { urls: matches },
+ ["blocking", "requestHeaders"]
+ );
+
+ const listeners = { onBeforeSendHeaders: listener };
+ if (blocks) {
+ const blistener = details => {
+ return { cancel: true };
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ blistener,
+ { urls: blocks },
+ ["blocking"]
+ );
+
+ listeners.onBeforeRequest = blistener;
+ }
+ this._activeListeners.set(override, listeners);
+ override.active = true;
+ }
+
+ onOverrideConfigChanged(override) {
+ // Check whether the override should be hidden from about:compat.
+ override.hidden = override.config.hidden;
+
+ // Setting the override's permanent pref overrules whether it is hidden.
+ if (override.permanentPrefEnabled !== undefined) {
+ override.hidden = !override.permanentPrefEnabled;
+ }
+
+ // Also check whether the override should be active.
+ let shouldBeActive = true;
+
+ // Overrides can be force-deactivated by their permanent preference.
+ if (override.permanentPrefEnabled === false) {
+ shouldBeActive = false;
+ }
+
+ // Overrides gated behind an experiment the user is not part of do not
+ // have to be activated, unless they are gathering telemetry, or the
+ // user has force-enabled them with their permanent pref.
+ if (override.config.experiment && override.permanentPrefEnabled !== true) {
+ shouldBeActive = false;
+ }
+
+ if (shouldBeActive) {
+ this.enableOverride(override);
+ } else {
+ this.disableOverride(override);
+ }
+
+ if (this._overridesEnabled) {
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableOverrides
+ ),
+ });
+ }
+ }
+
+ async registerUAOverrides() {
+ const platformMatches = ["all"];
+ let platformInfo = await browser.runtime.getPlatformInfo();
+ platformMatches.push(platformInfo.os == "android" ? "android" : "desktop");
+
+ for (const override of this._availableOverrides) {
+ if (platformMatches.includes(override.platform)) {
+ override.availableOnPlatform = true;
+ override.currentPlatform = platformInfo.os;
+
+ // If there is a specific about:config preference governing
+ // this override, monitor its state.
+ const pref = override.config.permanentPref;
+ override.permanentPrefEnabled =
+ pref && (await browser.aboutConfigPrefs.getPref(pref));
+ if (pref) {
+ const checkOverridePref = () => {
+ browser.aboutConfigPrefs.getPref(pref).then(value => {
+ override.permanentPrefEnabled = value;
+ this.onOverrideConfigChanged(override);
+ });
+ };
+ browser.aboutConfigPrefs.onPrefChange.addListener(
+ checkOverridePref,
+ pref
+ );
+ }
+
+ this.onOverrideConfigChanged(override);
+ }
+ }
+
+ this._overridesEnabled = true;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableOverrides
+ ),
+ });
+ }
+
+ unregisterUAOverrides() {
+ for (const override of this._availableOverrides) {
+ this.disableOverride(override);
+ }
+
+ this._overridesEnabled = false;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: false,
+ });
+ }
+
+ disableOverride(override) {
+ if (!override.active) {
+ return;
+ }
+
+ const listeners = this._activeListeners.get(override);
+ for (const [name, listener] of Object.entries(listeners)) {
+ browser.webRequest[name].removeListener(listener);
+ }
+ override.active = false;
+ this._activeListeners.delete(override);
+ }
+}
+
+module.exports = UAOverrides;