forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			432 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			432 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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, 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 shim.options;
 | |
|     } else if (message === "optIn") {
 | |
|       try {
 | |
|         await shim.onUserOptIn(new URL(url).hostname);
 | |
|         const { name, bug } = shim;
 | |
|         const origin = new URL(tab.url).origin;
 | |
|         warn(
 | |
|           "** User opted in for",
 | |
|           name,
 | |
|           "shim on",
 | |
|           origin,
 | |
|           "on tab",
 | |
|           id,
 | |
|           "frame",
 | |
|           frameId
 | |
|         );
 | |
|         const warning = `${name} is now being allowed on ${origin} for this browsing session. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
 | |
|         await browser.tabs.executeScript(id, {
 | |
|           code: `console.warn(${JSON.stringify(warning)})`,
 | |
|           frameId,
 | |
|           runAt: "document_start",
 | |
|         });
 | |
|       } 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;
 | 
