diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/extensions/webcompat/lib | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/webcompat/lib')
-rw-r--r-- | browser/extensions/webcompat/lib/about_compat_broker.js | 123 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/custom_functions.js | 96 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/injections.js | 163 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/intervention_helpers.js | 233 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/messaging_helper.js | 36 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/module_shim.js | 24 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/picture_in_picture_overrides.js | 74 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/shim_messaging_helper.js | 65 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/shims.js | 415 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/ua_overrides.js | 265 |
10 files changed, 1494 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..dc939b2b06 --- /dev/null +++ b/browser/extensions/webcompat/lib/about_compat_broker.js @@ -0,0 +1,123 @@ +/* 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.portsToAboutCompatTabs = this.buildPorts(); + + this._injections = bindings.injections; + this._injections.bindAboutCompatBroker(this); + + this._uaOverrides = bindings.uaOverrides; + this._uaOverrides.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 }; + }); + } + + getOverrideOrInterventionById(id) { + for (const [type, things] of Object.entries({ + overrides: this._uaOverrides.getAvailableOverrides(), + interventions: this._injections.getAvailableInjections(), + })) { + 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.getOverrideOrInterventionById(id); + if (!what) { + return Promise.reject( + `No such override or intervention to toggle: ${id}` + ); + } + this.portsToAboutCompatTabs + .broadcast({ toggling: id, active: what.active }) + .then(async () => { + switch (type) { + case "interventions": { + if (what.active) { + await this._injections.disableInjection(what); + } else { + await this._injections.enableInjection(what); + } + break; + } + case "overrides": { + if (what.active) { + await this._uaOverrides.disableOverride(what); + } else { + await this._uaOverrides.enableOverride(what); + } + break; + } + } + this.portsToAboutCompatTabs.broadcast({ + toggled: id, + active: what.active, + }); + }); + break; + } + case "getOverridesAndInterventions": { + return Promise.resolve({ + overrides: + (this._uaOverrides.isEnabled() && + this.filterOverrides( + this._uaOverrides.getAvailableOverrides() + )) || + false, + interventions: + (this._injections.isEnabled() && + this.filterOverrides( + this._injections.getAvailableInjections() + )) || + 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..9bfc6fbdb5 --- /dev/null +++ b/browser/extensions/webcompat/lib/custom_functions.js @@ -0,0 +1,96 @@ +/* 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; + }, + pdk5fix: injection => { + const { urls, types } = injection.data; + const listener = (injection.data.listener = ({ requestId }) => { + replaceStringInRequest( + requestId, + "VideoContextChromeAndroid", + "VideoContextAndroid" + ); + return {}; + }); + browser.webRequest.onBeforeRequest.addListener(listener, { urls, types }, [ + "blocking", + ]); + }, + pdk5fixDisable: injection => { + const { listener } = injection.data; + 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..6a7a6e8d4a --- /dev/null +++ b/browser/extensions/webcompat/lib/injections.js @@ -0,0 +1,163 @@ +/* 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 platformMatches = ["all"]; + let platformInfo = await browser.runtime.getPlatformInfo(); + platformMatches.push(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/picture_in_picture_overrides.js b/browser/extensions/webcompat/lib/picture_in_picture_overrides.js new file mode 100644 index 0000000000..febee193aa --- /dev/null +++ b/browser/extensions/webcompat/lib/picture_in_picture_overrides.js @@ -0,0 +1,74 @@ +/* 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 PictureInPictureOverrides { + constructor(availableOverrides) { + this.pref = "enable_picture_in_picture_overrides"; + this._prefEnabledOverrides = new Set(); + this._availableOverrides = availableOverrides; + this.policies = browser.pictureInPictureChild.getPolicies(); + } + + async _checkGlobalPref() { + await browser.aboutConfigPrefs.getPref(this.pref).then(value => { + if (value === false) { + this._enabled = false; + } else { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.pref, true); + } + this._enabled = true; + } + }); + } + + async _checkSpecificOverridePref(id, pref) { + const isDisabled = await browser.aboutConfigPrefs.getPref(pref); + if (isDisabled === true) { + this._prefEnabledOverrides.delete(id); + } else { + this._prefEnabledOverrides.add(id); + } + } + + bootup() { + const checkGlobal = async () => { + await this._checkGlobalPref(); + this._onAvailableOverridesChanged(); + }; + browser.aboutConfigPrefs.onPrefChange.addListener(checkGlobal, this.pref); + + const bootupPrefCheckPromises = [this._checkGlobalPref()]; + + for (const id of Object.keys(this._availableOverrides)) { + const pref = `disabled_picture_in_picture_overrides.${id}`; + const checkSingle = async () => { + await this._checkSpecificOverridePref(id, pref); + this._onAvailableOverridesChanged(); + }; + browser.aboutConfigPrefs.onPrefChange.addListener(checkSingle, pref); + bootupPrefCheckPromises.push(this._checkSpecificOverridePref(id, pref)); + } + + Promise.all(bootupPrefCheckPromises).then(() => { + this._onAvailableOverridesChanged(); + }); + } + + async _onAvailableOverridesChanged() { + const policies = await this.policies; + let enabledOverrides = {}; + for (const [id, override] of Object.entries(this._availableOverrides)) { + const enabled = this._enabled && this._prefEnabledOverrides.has(id); + for (const [url, policy] of Object.entries(override)) { + enabledOverrides[url] = enabled ? policy : policies.DEFAULT; + } + } + browser.pictureInPictureParent.setOverrides(enabledOverrides); + } +} 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..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; diff --git a/browser/extensions/webcompat/lib/ua_overrides.js b/browser/extensions/webcompat/lib/ua_overrides.js new file mode 100644 index 0000000000..7024583e4e --- /dev/null +++ b/browser/extensions/webcompat/lib/ua_overrides.js @@ -0,0 +1,265 @@ +/* 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, telemetryKey, uaTransformer } = override.config; + const listener = details => { + // We set the "used" telemetry key if the user would have had the + // override applied, regardless of whether it is actually applied. + if (!details.frameId && override.shouldSendDetailedTelemetry) { + // For now, we only care about Telemetry on Fennec, where telemetry + // is sent in Java code (as part of the core ping). That code must + // be aware of each key we send, which we send as a SharedPreference. + browser.sharedPreferences.setBoolPref(`${telemetryKey}Used`, true); + } + + // 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.experimentActive || + 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; + + // If telemetry is being collected, note the addon version. + if (telemetryKey) { + const { version } = browser.runtime.getManifest(); + browser.sharedPreferences.setCharPref(`${telemetryKey}Version`, version); + } + + // If collecting detailed telemetry on the override, note that it was activated. + if (override.shouldSendDetailedTelemetry) { + browser.sharedPreferences.setBoolPref(`${telemetryKey}Ready`, true); + } + } + + onOverrideConfigChanged(override) { + // Check whether the override should be hidden from about:compat. + override.hidden = override.config.hidden; + + // Also hide if the override is in an experiment the user is not part of. + if (override.config.experiment && !override.experimentActive) { + override.hidden = true; + } + + // 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; + } + + // Only send detailed telemetry if the user is actively in an experiment or + // has opted into an experimental feature. + override.shouldSendDetailedTelemetry = + override.config.telemetryKey && + (override.experimentActive || override.permanentPrefEnabled); + + // 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.experimentActive && + !override.config.telemetryKey && + 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; + + // Note whether the user is actively in the override's experiment (if any). + override.experimentActive = false; + const experiment = override.config.experiment; + if (experiment) { + // We expect the definition to have either one string for 'experiment' + // (just one branch) or an array of strings (multiple branches). So + // here we turn the string case into a one-element array for the loop. + const branches = Array.isArray(experiment) + ? experiment + : [experiment]; + for (const branch of branches) { + if (await browser.experiments.isActive(branch)) { + override.experimentActive = true; + break; + } + } + } + + // 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; |