diff options
Diffstat (limited to 'browser/extensions/webcompat/lib/shims.js')
-rw-r--r-- | browser/extensions/webcompat/lib/shims.js | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js new file mode 100644 index 0000000000..638411007b --- /dev/null +++ b/browser/extensions/webcompat/lib/shims.js @@ -0,0 +1,415 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals browser, module, onMessageFromTab */ + +const releaseBranchPromise = browser.appConstants.getReleaseBranch(); + +const platformPromise = browser.runtime.getPlatformInfo().then(info => { + return info.os === "android" ? "android" : "desktop"; +}); + +let debug = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.debug.apply(this, arguments); + } +}; +let error = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.error.apply(this, arguments); + } +}; +let warn = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.warn.apply(this, arguments); + } +}; + +class Shim { + constructor(opts) { + const { matches, unblocksOnOptIn } = opts; + + this.branches = opts.branches; + this.bug = opts.bug; + this.file = opts.file; + this.hosts = opts.hosts; + this.id = opts.id; + this.matches = matches; + this.name = opts.name; + this.notHosts = opts.notHosts; + this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP; + this._options = opts.options || {}; + this.needsShimHelpers = opts.needsShimHelpers; + this.platform = opts.platform || "all"; + this.unblocksOnOptIn = unblocksOnOptIn; + + this._hostOptIns = new Set(); + + this._disabledByConfig = opts.disabled; + this._disabledGlobally = false; + this._disabledByPlatform = false; + this._disabledByReleaseBranch = false; + + const pref = `disabled_shims.${this.id}`; + + browser.aboutConfigPrefs.onPrefChange.addListener(async () => { + const value = await browser.aboutConfigPrefs.getPref(pref); + this._disabledPrefValue = value; + this._onEnabledStateChanged(); + }, pref); + + this.ready = Promise.all([ + browser.aboutConfigPrefs.getPref(pref).then(value => { + this._disabledPrefValue = value; + }), + platformPromise.then(platform => { + this._disabledByPlatform = + this.platform !== "all" && this.platform !== platform; + return platform; + }), + releaseBranchPromise.then(branch => { + this._disabledByReleaseBranch = + this.branches && !this.branches.includes(branch); + return branch; + }), + ]).then(([_, platform, branch]) => { + this._preprocessOptions(platform, branch); + this._onEnabledStateChanged(); + }); + } + + _preprocessOptions(platform, branch) { + // options may be any value, but can optionally be gated for specified + // platform/branches, if in the format `{value, branches, platform}` + this.options = {}; + for (const [k, v] of Object.entries(this._options)) { + if (v?.value) { + if ( + (!v.platform || v.platform === platform) && + (!v.branches || v.branches.includes(branch)) + ) { + this.options[k] = v.value; + } + } else { + this.options[k] = v; + } + } + } + + get enabled() { + if (this._disabledGlobally) { + return false; + } + + if (this._disabledPrefValue !== undefined) { + return !this._disabledPrefValue; + } + + return ( + !this._disabledByConfig && + !this._disabledByPlatform && + !this._disabledByReleaseBranch + ); + } + + enable() { + this._disabledGlobally = false; + this._onEnabledStateChanged(); + } + + disable() { + this._disabledGlobally = true; + this._onEnabledStateChanged(); + } + + _onEnabledStateChanged() { + if (!this.enabled) { + return this._revokeRequestsInETP(); + } + return this._allowRequestsInETP(); + } + + _allowRequestsInETP() { + return browser.trackingProtection.allow(this.id, this.matches, { + hosts: this.hosts, + notHosts: this.notHosts, + }); + } + + _revokeRequestsInETP() { + return browser.trackingProtection.revoke(this.id); + } + + meantForHost(host) { + const { hosts, notHosts } = this; + if (hosts || notHosts) { + if ( + (notHosts && notHosts.includes(host)) || + (hosts && !hosts.includes(host)) + ) { + return false; + } + } + return true; + } + + isTriggeredByURL(url) { + if (!this.matches) { + return false; + } + + if (!this._matcher) { + this._matcher = browser.matchPatterns.getMatcher(this.matches); + } + + return this._matcher.matches(url); + } + + async onUserOptIn(host) { + const { unblocksOnOptIn } = this; + if (unblocksOnOptIn) { + await browser.trackingProtection.allow(this.id, unblocksOnOptIn, { + hosts: [host], + }); + } + + this._hostOptIns.add(host); + } + + hasUserOptedInAlready(host) { + return this._hostOptIns.has(host); + } +} + +class Shims { + constructor(availableShims) { + if (!browser.trackingProtection) { + console.error("Required experimental add-on APIs for shims unavailable"); + return; + } + + this._registerShims(availableShims); + + onMessageFromTab(this._onMessageFromShim.bind(this)); + + this.ENABLED_PREF = "enable_shims"; + browser.aboutConfigPrefs.onPrefChange.addListener(() => { + this._checkEnabledPref(); + }, this.ENABLED_PREF); + this._haveCheckedEnabledPref = this._checkEnabledPref(); + } + + _registerShims(shims) { + if (this.shims) { + throw new Error("_registerShims has already been called"); + } + + this.shims = new Map(); + for (const shimOpts of shims) { + const { id } = shimOpts; + if (!this.shims.has(id)) { + this.shims.set(shimOpts.id, new Shim(shimOpts)); + } + } + + const allShimPatterns = new Set(); + for (const { matches } of this.shims.values()) { + for (const matchPattern of matches) { + allShimPatterns.add(matchPattern); + } + } + + if (!allShimPatterns.size) { + debug("Skipping shims; none enabled"); + return; + } + + const urls = [...allShimPatterns]; + debug("Shimming these match patterns", urls); + + browser.webRequest.onBeforeRequest.addListener( + this._ensureShimForRequestOnTab.bind(this), + { urls, types: ["script"] }, + ["blocking"] + ); + } + + async _checkEnabledPref() { + await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true); + } else if (value === false) { + this.enabled = false; + } else { + this.enabled = true; + } + }); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (enabled === this._enabled) { + return; + } + + this._enabled = enabled; + + for (const shim of this.shims.values()) { + if (enabled) { + shim.enable(); + } else { + shim.disable(); + } + } + } + + async _onMessageFromShim(payload, sender, sendResponse) { + const { tab } = sender; + const { id, url } = tab; + const { shimId, message } = payload; + + // Ignore unknown messages (for instance, from about:compat). + if (message !== "getOptions" && message !== "optIn") { + return undefined; + } + + if (sender.id !== browser.runtime.id || id === -1) { + throw new Error("not allowed"); + } + + // Important! It is entirely possible for sites to spoof + // these messages, due to shims allowing web pages to + // communicate with the extension. + + const shim = this.shims.get(shimId); + if (!shim?.needsShimHelpers?.includes(message)) { + throw new Error("not allowed"); + } + + if (message === "getOptions") { + return shim.options; + } else if (message === "optIn") { + try { + await shim.onUserOptIn(new URL(url).hostname); + warn("** User opted in on tab ", id, "for", shimId); + } catch (err) { + console.error(err); + throw new Error("error"); + } + } + + return undefined; + } + + async _ensureShimForRequestOnTab(details) { + await this._haveCheckedEnabledPref; + + if (!this.enabled) { + return undefined; + } + + // We only ever reach this point if a request is for a URL which ought to + // be shimmed. We never get here if a request is blocked, and we only + // unblock requests if at least one shim matches it. + + const { frameId, originUrl, requestId, tabId, url } = details; + + // Ignore requests unrelated to tabs + if (tabId < 0) { + return undefined; + } + + // We need to base our checks not on the frame's host, but the tab's. + const topHost = new URL((await browser.tabs.get(tabId)).url).hostname; + const unblocked = await browser.trackingProtection.wasRequestUnblocked( + requestId + ); + + let shimToApply; + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled) { + continue; + } + + // Do not apply the shim if it is only meant to apply when strict mode ETP + // (content blocking) was going to block the request. + if (!unblocked && shim.onlyIfBlockedByETP) { + continue; + } + + if (!shim.meantForHost(topHost)) { + continue; + } + + // If the user has already opted in for this shim, all requests it covers + // should be allowed; no need for a shim anymore. + if (shim.hasUserOptedInAlready(topHost)) { + return undefined; + } + + // If this URL isn't meant for this shim, don't apply it. + if (!shim.isTriggeredByURL(url)) { + continue; + } + + shimToApply = shim; + break; + } + + if (shimToApply) { + // Note that sites may request the same shim twice, but because the requests + // may differ enough for some to fail (CSP/CORS/etc), we always re-run the + // shim JS just in case. Shims should gracefully handle this as well. + const { bug, file, id, name, needsShimHelpers } = shimToApply; + warn("Shimming", name, "on tabId", tabId, "frameId", frameId); + + const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; + + try { + if (needsShimHelpers?.length) { + await browser.tabs.executeScript(tabId, { + file: "/lib/shim_messaging_helper.js", + frameId, + runAt: "document_start", + }); + const origin = new URL(originUrl).origin; + await browser.tabs.sendMessage( + tabId, + { origin, shimId: id, needsShimHelpers, warning }, + { frameId } + ); + } else { + await browser.tabs.executeScript(tabId, { + code: `console.warn(${JSON.stringify(warning)})`, + frameId, + runAt: "document_start", + }); + } + } catch (_) {} + + // If any shims matched the script to replace it, then let the original + // request complete without ever hitting the network, with a blank script. + return { redirectUrl: browser.runtime.getURL(`shims/${file}`) }; + } + + // Sanity check: if no shims are over-riding a given URL and it was meant to + // be blocked by ETP, then block it. + if (unblocked) { + error("unexpected:", url, "was not shimmed, and had to be re-blocked"); + return { cancel: true }; + } + + debug("allowing", url); + return undefined; + } +} + +module.exports = Shims; |