/* 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;