diff options
Diffstat (limited to 'browser/extensions/webcompat/lib')
-rw-r--r-- | browser/extensions/webcompat/lib/about_compat_broker.js | 141 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/custom_functions.js | 109 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/injections.js | 272 | ||||
-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/requestStorageAccess_helper.js | 30 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/shim_messaging_helper.js | 65 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/shims.js | 1110 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/ua_helpers.js | 99 | ||||
-rw-r--r-- | browser/extensions/webcompat/lib/ua_overrides.js | 210 |
11 files changed, 2329 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..faaa56a38e --- /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..92fdc5fbb3 --- /dev/null +++ b/browser/extensions/webcompat/lib/injections.js @@ -0,0 +1,272 @@ +/* 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 Set(); + // Only used if this.shouldUseScriptingAPI is false and we are falling back + // to use the contentScripts API. + this._activeInjectionHandles = new Map(); + this._customFunctions = customFunctions; + + this.shouldUseScriptingAPI = + browser.aboutConfigPrefs.getBoolPrefSync("useScriptingAPI"); + // Debug log emit only on nightly (similarly to the debug + // helper used in shims.js for similar purpose). + browser.appConstants.getReleaseBranch().then(releaseBranch => { + if (releaseBranch !== "release_or_beta") { + console.debug( + `WebCompat Injections will be injected using ${ + this.shouldUseScriptingAPI ? "scripting" : "contentScripts" + } API` + ); + } + }); + } + + 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 getPromiseRegisteredScriptIds(scriptIds) { + let registeredScriptIds = []; + + // Try to avoid re-registering scripts already registered + // (e.g. if the webcompat background page is restarted + // after an extension process crash, after having registered + // the content scripts already once), but do not prevent + // to try registering them again if the getRegisteredContentScripts + // method returns an unexpected rejection. + try { + const registeredScripts = + await browser.scripting.getRegisteredContentScripts({ + // By default only look for script ids that belongs to Injections + // (and ignore the ones that may belong to Shims). + ids: scriptIds ?? this._availableInjections.map(inj => inj.id), + }); + registeredScriptIds = registeredScripts.map(script => script.id); + } catch (ex) { + console.error( + "Retrieve WebCompat GoFaster registered content scripts failed: ", + ex + ); + } + + return registeredScriptIds; + } + + async registerContentScripts() { + const platformInfo = await browser.runtime.getPlatformInfo(); + const platformMatches = [ + "all", + platformInfo.os, + platformInfo.os == "android" ? "android" : "desktop", + ]; + + let registeredScriptIds = this.shouldUseScriptingAPI + ? await this.getPromiseRegisteredScriptIds() + : []; + + for (const injection of this._availableInjections) { + if (platformMatches.includes(injection.platform)) { + injection.availableOnPlatform = true; + await this.enableInjection(injection, registeredScriptIds); + } + } + + this._injectionsEnabled = true; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + interventionsChanged: this._aboutCompatBroker.filterOverrides( + this._availableInjections + ), + }); + } + + buildContentScriptRegistrations(contentScripts) { + let finalConfig = Object.assign({}, contentScripts); + + if (!finalConfig.runAt) { + finalConfig.runAt = "document_start"; + } + + if (this.shouldUseScriptingAPI) { + // Don't persist the content scripts across browser restarts + // (at least not yet, we would need to apply some more changes + // to adjust webcompat for accounting for the scripts to be + // already registered). + // + // NOTE: scripting API has been introduced in Gecko 102, + // prior to Gecko 105 persistAcrossSessions option was required + // and only accepted false persistAcrossSessions, after Gecko 105 + // is optional and defaults to true. + + finalConfig.persistAcrossSessions = false; + + // Convert js/css from contentScripts.register API method + // format to scripting.registerContentScripts API method + // format. + if (Array.isArray(finalConfig.js)) { + finalConfig.js = finalConfig.js.map(e => e.file); + } + + if (Array.isArray(finalConfig.css)) { + finalConfig.css = finalConfig.css.map(e => e.file); + } + } + + return finalConfig; + } + + async enableInjection(injection, registeredScriptIds) { + if (injection.active) { + return undefined; + } + + if (injection.customFunc) { + return this.enableCustomInjection(injection); + } + + return this.enableContentScripts(injection, registeredScriptIds); + } + + 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, registeredScriptIds) { + let injectProps; + try { + const { id } = injection; + if (this.shouldUseScriptingAPI) { + // enableContentScripts receives a registeredScriptIds already + // pre-computed once from registerContentScripts to register all + // the injection, whereas it does not expect to receive one when + // it is called from the AboutCompatBroker to re-enable one specific + // injection. + let activeScriptIds = Array.isArray(registeredScriptIds) + ? registeredScriptIds + : await this.getPromiseRegisteredScriptIds([id]); + injectProps = this.buildContentScriptRegistrations( + injection.contentScripts + ); + injectProps.id = id; + if (!activeScriptIds.includes(id)) { + await browser.scripting.registerContentScripts([injectProps]); + } + this._activeInjections.add(id); + } else { + const handle = await browser.contentScripts.register( + this.buildContentScriptRegistrations(injection.contentScripts) + ); + this._activeInjections.add(id); + this._activeInjectionHandles.set(id, handle); + } + + injection.active = true; + } catch (ex) { + console.error( + "Registering WebCompat GoFaster content scripts failed: ", + { injection, injectProps }, + 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) { + if (this._activeInjections.has(injection.id)) { + if (this.shouldUseScriptingAPI) { + await browser.scripting.unregisterContentScripts({ + ids: [injection.id], + }); + } else { + const handle = this._activeInjectionHandles.get(injection.id); + await handle.unregister(); + this._activeInjectionHandles.delete(injection.id); + } + 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..d978ed384f --- /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..fedb4c38e9 --- /dev/null +++ b/browser/extensions/webcompat/lib/shims.js @@ -0,0 +1,1110 @@ +/* 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.shouldUseScriptingAPI = + browser.aboutConfigPrefs.getBoolPrefSync("useScriptingAPI"); + debug( + `WebCompat Shim ${this.id} will be injected using ${ + this.shouldUseScriptingAPI ? "scripting" : "contentScripts" + } API` + ); + + 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; + + // NOTE: _contentScriptRegistrations is an array of string ids when + // shouldUseScriptingAPI is true and an array of script handles returned + // by contentScripts.register otherwise. + this._contentScriptRegistrations = []; + + this.contentScripts = contentScripts || []; + for (const script of this.contentScripts) { + if (typeof script.css === "string") { + script.css = [ + this.shouldUseScriptingAPI + ? `/shims/${script.css}` + : { file: `/shims/${script.css}` }, + ]; + } + if (typeof script.js === "string") { + script.js = [ + this.shouldUseScriptingAPI + ? `/shims/${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 = []; + let idx = 0; + for (const options of this.contentScripts) { + matches.push(options.matches); + if (this.shouldUseScriptingAPI) { + // Some shims includes more than one script (e.g. Blogger one contains + // a content script to be run on document_start and one to be run + // on document_end. + options.id = `shim-${this.id}-${idx++}`; + options.persistAcrossSessions = false; + // Having to call getRegisteredContentScripts each time we are going to + // register a Shim content script is suboptimal, but avoiding that + // may require a bit more changes (e.g. rework both Injections, Shim and Shims + // classes to more easily register all content scripts with a single + // call to the scripting API methods when the background script page is loading + // and one per injection or shim being enabled from the AboutCompatBroker). + // In the short term we call getRegisteredContentScripts and restrict it to + // the script id we are about to register. + let isAlreadyRegistered = false; + try { + const registeredScripts = + await browser.scripting.getRegisteredContentScripts({ + ids: [options.id], + }); + isAlreadyRegistered = !!registeredScripts.length; + } catch (ex) { + console.error( + "Retrieve WebCompat GoFaster registered content scripts failed: ", + ex + ); + } + try { + if (!isAlreadyRegistered) { + await browser.scripting.registerContentScripts([options]); + } + this._contentScriptRegistrations.push(options.id); + } catch (ex) { + console.error( + "Registering WebCompat Shim content scripts failed: ", + options, + ex + ); + } + } else { + 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() { + if (this.shouldUseScriptingAPI) { + const ids = this._contentScriptRegistrations; + await browser.scripting.unregisterContentScripts({ ids }); + } else { + 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..2cc848f8b0 --- /dev/null +++ b/browser/extensions/webcompat/lib/ua_helpers.js @@ -0,0 +1,99 @@ +/* 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"); + }, + capRvTo109(originalUA) { + const ver = originalUA.match(/rv:(\d+\.\d+)/); + if (!ver || parseFloat(ver[1]) <= 109) { + return originalUA; + } + return originalUA.replace(`rv:${ver[1]}`, "rv:109.0"); + }, + capVersionToNumber(originalUA, cap = 120) { + const ver = originalUA.match(/Firefox\/(\d+\.\d+)/); + if (!ver || parseFloat(ver[1]) <= cap) { + return originalUA; + } + const capped = `Firefox/${cap}.0`; + return originalUA.replace(`Firefox/${ver[1]}`, capped); + }, + getWindowsUA(originalUA) { + const rv = originalUA.match("rv:[0-9]+.[0-9]+")[0]; + const ver = originalUA.match("Firefox/[0-9]+.[0-9]+")[0]; + return `Mozilla/5.0 (Windows NT 10.0; Win64; x64; ${rv}) Gecko/20100101 ${ver}`; + }, +}; + +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; |