diff options
Diffstat (limited to 'toolkit/mozapps/handling/ContentDispatchChooser.jsm')
-rw-r--r-- | toolkit/mozapps/handling/ContentDispatchChooser.jsm | 595 |
1 files changed, 595 insertions, 0 deletions
diff --git a/toolkit/mozapps/handling/ContentDispatchChooser.jsm b/toolkit/mozapps/handling/ContentDispatchChooser.jsm new file mode 100644 index 0000000000..520189bbd5 --- /dev/null +++ b/toolkit/mozapps/handling/ContentDispatchChooser.jsm @@ -0,0 +1,595 @@ +/* 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/. */ + +// Constants + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const DIALOG_URL_APP_CHOOSER = + "chrome://mozapps/content/handling/appChooser.xhtml"; +const DIALOG_URL_PERMISSION = + "chrome://mozapps/content/handling/permissionDialog.xhtml"; + +var EXPORTED_SYMBOLS = [ + "nsContentDispatchChooser", + "ContentDispatchChooserTelemetry", +]; + +const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; +const PERMISSION_KEY_DELIMITER = "^"; + +let ContentDispatchChooserTelemetry = { + /** + * Maps protocol scheme to telemetry label. + */ + SCHEME_TO_LABEL: { + bingmaps: "BING", + bingweather: "BING", + fb: "FACEBOOK", + fbmessenger: "FACEBOOK", + findmy: "APPLE_FINDMY", + findmyfriends: "APPLE_FINDMY", + fmf1: "APPLE_FINDMY", + fmip1: "APPLE_FINDMY", + git: "GIT", + "git-client": "GIT", + grenada: "APPLE_FINDMY", + ichat: "IMESSAGE", + im: "INSTANT_MESSAGE", + imessage: "IMESSAGE", + ipp: "IPP", + ipps: "IPP", + irc: "IRC", + irc6: "IRC", + ircs: "IRC", + itals: "APPLE_LIVESTREAM", + italss: "APPLE_LIVESTREAM", + itls: "APPLE_LIVESTREAM", + itlss: "APPLE_LIVESTREAM", + itms: "APPLE_MUSIC", + itmss: "APPLE_MUSIC", + itsradio: "APPLE_MUSIC", + itunes: "APPLE_MUSIC", + itunesradio: "APPLE_MUSIC", + itvls: "APPLE_LIVESTREAM", + itvlss: "APPLE_LIVESTREAM", + macappstore: "MACAPPSTORE", + macappstores: "MACAPPSTORE", + map: "MAP", + mapitem: "MAP", + maps: "MAP", + message: "MESSAGE", + messages: "MESSAGE", + microsoftmusic: "MICROSOFT_APP", + microsoftvideo: "MICROSOFT_APP", + mswindowsmusic: "MICROSOFT_APP", + music: "MUSIC", + musics: "MUSIC", + onenote: "ONENOTE", + "onenote-cmd": "ONENOTE", + pcast: "PODCAST", + podcast: "PODCAST", + podcasts: "PODCAST", + search: "SEARCH", + "search-ms": "SEARCH", + sip: "SIP", + sips: "SIP", + skype: "SKYPE", + "skype-meetnow": "SKYPE", + skypewin: "SKYPE", + tg: "TELEGRAM", + tv: "TELEVISION", + zoommtg: "ZOOM", + zoompbx: "ZOOM", + zoomus: "ZOOM", + zune: "MICROSOFT_APP", + }, + + /** + * Maps protocol scheme prefix to telemetry label. + */ + SCHEME_PREFIX_TO_LABEL: { + apple: "APPLE", + "com.microsoft": "MICROSOFT_APP", + facetime: "FACETIME", + "fb-messenger": "FACEBOOK", + icloud: "ICLOUD", + "itms-": "APPLE_MUSIC", + microsoft: "MICROSOFT_APP", + "ms-": "MICROSOFT_APP", + outlook: "OUTLOOK", + photos: "PHOTOS", + "web+": "WEBHANDLER", + windows: "WINDOWS_PREFIX", + "x-apple": "APPLE", + xbox: "XBOX", + }, + + /** + * Sandbox flags for telemetry + * Copied from nsSandboxFlags.h + */ + SANDBOXED_AUXILIARY_NAVIGATION: 0x2, + SANDBOXED_TOPLEVEL_NAVIGATION: 0x4, + SANDBOXED_TOPLEVEL_NAVIGATION_USER_ACTIVATION: 0x20000, + + /** + * Lazy getter for labels of the external protocol navigation telemetry probe. + * @returns {string[]} - An array of histogram labels. + */ + get _telemetryLabels() { + if (!this._telemetryLabelArray) { + this._telemetryLabelArray = Services.telemetry.getCategoricalLabels().EXTERNAL_PROTOCOL_HANDLER_DIALOG_CONTEXT_SCHEME; + } + return this._telemetryLabelArray; + }, + + /** + * Get histogram label by protocol scheme. + * @param {string} aScheme - Protocol scheme to map to histogram label. + * @returns {string} - Label. + */ + _getTelemetryLabel(aScheme) { + if (!aScheme) { + throw new Error("Invalid scheme"); + } + let labels = this._telemetryLabels; + + // Custom scheme-to-label mappings + let mappedLabel = this.SCHEME_TO_LABEL[aScheme]; + if (mappedLabel) { + return mappedLabel; + } + + // Prefix mappings + for (let prefix of Object.keys(this.SCHEME_PREFIX_TO_LABEL)) { + if (aScheme.startsWith(prefix)) { + return this.SCHEME_PREFIX_TO_LABEL[prefix]; + } + } + + // Test if we have a label for the protocol scheme. + // If not, we use the "OTHER" label. + if (labels.includes(aScheme)) { + return aScheme; + } + + return "OTHER"; + }, + + /** + * Determine if a load was triggered from toplevel or an iframe + * (cross origin, same origin, sandboxed). + * + * @param {BrowsingContext} [aBrowsingContext] - Context of the load. + * @param {nsIPrincipal} [aTriggeringPrincipal] - Principal which triggered + * the load. + * @returns {string} - Histogram key. May return "UNKNOWN". + */ + _getTelemetryKey(aBrowsingContext, aTriggeringPrincipal) { + if (!aBrowsingContext) { + return "UNKNOWN"; + } + if (aBrowsingContext.top == aBrowsingContext) { + return "TOPLEVEL"; + } + + let { sandboxFlags } = aBrowsingContext; + if (sandboxFlags) { + // Iframe is sandboxed. Determine whether it sets allow flags relevant + // for the external protocol navigation. + if ( + !(sandboxFlags & this.SANDBOXED_TOPLEVEL_NAVIGATION) || + !(sandboxFlags & this.SANDBOXED_TOPLEVEL_NAVIGATION_USER_ACTIVATION) || + !(sandboxFlags & this.SANDBOXED_AUXILIARY_NAVIGATION) + ) { + return "SUB_SANDBOX_ALLOW"; + } + return "SUB_SANDBOX_NOALLOW"; + } + + // We're in a frame, check if the frame is cross origin with the top context. + if (!aTriggeringPrincipal) { + return "UNKNOWN"; + } + + let topLevelPrincipal = + aBrowsingContext.top.embedderElement?.contentPrincipal; + if (!topLevelPrincipal) { + return "UNKNOWN"; + } + + if (topLevelPrincipal.isThirdPartyPrincipal(aTriggeringPrincipal)) { + return "SUB_CROSSORIGIN"; + } + + return "SUB_SAMEORIGIN"; + }, + + /** + * Record telemetry for the external protocol handler dialog. + * @param {string} aScheme - Scheme of the protocol being loaded. + * @param {BrowsingContext} [aBrowsingContext] - Context of the load. + * @param {nsIPrincipal} [aTriggeringPrincipal] - Principal which triggered + * the load. + */ + recordTelemetry(aScheme, aBrowsingContext, aTriggeringPrincipal) { + let type = this._getTelemetryKey(aBrowsingContext, aTriggeringPrincipal); + let label = this._getTelemetryLabel(aScheme); + + Services.telemetry + .getKeyedHistogramById("EXTERNAL_PROTOCOL_HANDLER_DIALOG_CONTEXT_SCHEME") + .add(type, label); + }, +}; + +class nsContentDispatchChooser { + /** + * Prompt the user to open an external application. + * If the triggering principal doesn't have permission to open apps for the + * protocol of aURI, we show a permission prompt first. + * If the caller has permission and a preferred handler is set, we skip the + * dialogs and directly open the handler. + * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers. + * @param {nsIURI} aURI - URI to be handled. + * @param {nsIPrincipal} [aPrincipal] - Principal which triggered the load. + * @param {BrowsingContext} [aBrowsingContext] - Context of the load. + */ + async handleURI(aHandler, aURI, aPrincipal, aBrowsingContext) { + let callerHasPermission = this._hasProtocolHandlerPermission( + aHandler.type, + aPrincipal + ); + + // Skip the dialog if a preferred application is set and the caller has + // permission. + if ( + callerHasPermission && + !aHandler.alwaysAskBeforeHandling && + (aHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp || + aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault) + ) { + try { + aHandler.launchWithURI(aURI, aBrowsingContext); + } catch (error) { + // We are not supposed to ask, but when file not found the user most likely + // uninstalled the application which handles the uri so we will continue + // by application chooser dialog. + if (error.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + aHandler.alwaysAskBeforeHandling = true; + } else { + throw error; + } + } + } + + // We will show a prompt, record telemetry. + try { + ContentDispatchChooserTelemetry.recordTelemetry( + aHandler.type, + aBrowsingContext, + aPrincipal + ); + } catch (error) { + Cu.reportError(error); + } + + let shouldOpenHandler = false; + try { + shouldOpenHandler = await this._prompt( + aHandler, + aPrincipal, + callerHasPermission, + aBrowsingContext + ); + } catch (error) { + Cu.reportError(error.message); + } + + if (!shouldOpenHandler) { + return; + } + + // Site was granted permission and user chose to open application. + // Launch the external handler. + aHandler.launchWithURI(aURI, aBrowsingContext); + } + + /** + * Get the name of the application set to handle the the protocol. + * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers. + * @returns {string|null} - Human readable handler name or null if the user + * is expected to set a handler. + */ + _getHandlerName(aHandler) { + if (aHandler.alwaysAskBeforeHandling) { + return null; + } + if ( + aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault && + aHandler.hasDefaultHandler + ) { + return aHandler.defaultDescription; + } + return aHandler.preferredApplicationHandler?.name; + } + + /** + * Show permission or/and app chooser prompt. + * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers. + * @param {nsIPrincipal} aPrincipal - Principal which triggered the load. + * @param {boolean} aHasPermission - Whether the caller has permission to + * open the protocol. + * @param {BrowsingContext} [aBrowsingContext] - Context associated with the + * protocol navigation. + */ + async _prompt(aHandler, aPrincipal, aHasPermission, aBrowsingContext) { + let shouldOpenHandler = false; + let resetHandlerChoice = false; + + // If caller does not have permission, prompt the user. + if (!aHasPermission) { + let canPersistPermission = this._isSupportedPrincipal(aPrincipal); + + let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + // Whether the permission request was granted + outArgs.setProperty("granted", false); + // If the user wants to select a new application for the protocol. + // This will cause us to show the chooser dialog, even if an app is set. + outArgs.setProperty("resetHandlerChoice", null); + // If the we should store the permission and not prompt again for it. + outArgs.setProperty("remember", null); + + await this._openDialog( + DIALOG_URL_PERMISSION, + { + handler: aHandler, + principal: aPrincipal, + browsingContext: aBrowsingContext, + outArgs, + canPersistPermission, + preferredHandlerName: this._getHandlerName(aHandler), + }, + aBrowsingContext + ); + if (!outArgs.getProperty("granted")) { + // User denied request + return false; + } + + // Check if user wants to set a new application to handle the protocol. + resetHandlerChoice = outArgs.getProperty("resetHandlerChoice"); + + // If the user wants to select a new app we don't persist the permission. + if (!resetHandlerChoice && aPrincipal) { + let remember = outArgs.getProperty("remember"); + this._updatePermission(aPrincipal, aHandler.type, remember); + } + + shouldOpenHandler = true; + } + + // Prompt if the user needs to make a handler choice for the protocol. + if (aHandler.alwaysAskBeforeHandling || resetHandlerChoice) { + // User has not set a preferred application to handle this protocol scheme. + // Open the application chooser dialog + let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + outArgs.setProperty("openHandler", false); + outArgs.setProperty("preferredAction", aHandler.preferredAction); + outArgs.setProperty( + "preferredApplicationHandler", + aHandler.preferredApplicationHandler + ); + outArgs.setProperty( + "alwaysAskBeforeHandling", + aHandler.alwaysAskBeforeHandling + ); + let usePrivateBrowsing = aBrowsingContext?.usePrivateBrowsing; + await this._openDialog( + DIALOG_URL_APP_CHOOSER, + { + handler: aHandler, + outArgs, + usePrivateBrowsing, + enableButtonDelay: aHasPermission, + }, + aBrowsingContext + ); + + shouldOpenHandler = outArgs.getProperty("openHandler"); + + // If the user accepted the dialog, apply their selection. + if (shouldOpenHandler) { + for (let prop of [ + "preferredAction", + "preferredApplicationHandler", + "alwaysAskBeforeHandling", + ]) { + aHandler[prop] = outArgs.getProperty(prop); + } + + // Store handler data + Cc["@mozilla.org/uriloader/handler-service;1"] + .getService(Ci.nsIHandlerService) + .store(aHandler); + } + } + + return shouldOpenHandler; + } + + /** + * Test if a given principal has the open-protocol-handler permission for a + * specific protocol. + * @param {string} scheme - Scheme of the protocol. + * @param {nsIPrincipal} aPrincipal - Principal to test for permission. + * @returns {boolean} - true if permission is set, false otherwise. + */ + _hasProtocolHandlerPermission(scheme, aPrincipal) { + // Permission disabled by pref + if (!nsContentDispatchChooser.isPermissionEnabled) { + return true; + } + + // If a handler is set to open externally by default we skip the dialog. + if ( + Services.prefs.getBoolPref( + "network.protocol-handler.external." + scheme, + false + ) + ) { + return true; + } + + if (!aPrincipal) { + return false; + } + + if (aPrincipal.isAddonOrExpandedAddonPrincipal) { + return true; + } + + let key = this._getSkipProtoDialogPermissionKey(scheme); + return ( + Services.perms.testPermissionFromPrincipal(aPrincipal, key) === + Services.perms.ALLOW_ACTION + ); + } + + /** + * Get open-protocol-handler permission key for a protocol. + * @param {string} aProtocolScheme - Scheme of the protocol. + * @returns {string} - Permission key. + */ + _getSkipProtoDialogPermissionKey(aProtocolScheme) { + return ( + PROTOCOL_HANDLER_OPEN_PERM_KEY + + PERMISSION_KEY_DELIMITER + + aProtocolScheme + ); + } + + /** + * Opens a dialog as a SubDialog on tab level. + * If we don't have a BrowsingContext we will fallback to a standalone window. + * @param {string} aDialogURL - URL of the dialog to open. + * @param {Object} aDialogArgs - Arguments passed to the dialog. + * @param {BrowsingContext} [aBrowsingContext] - BrowsingContext associated + * with the tab the dialog is associated with. + */ + async _openDialog(aDialogURL, aDialogArgs, aBrowsingContext) { + // Make the app chooser dialog resizable + let resizable = `resizable=${ + aDialogURL == DIALOG_URL_APP_CHOOSER ? "yes" : "no" + }`; + + if (aBrowsingContext) { + if (!aBrowsingContext.topChromeWindow) { + throw new Error( + "Can't show external protocol dialog. BrowsingContext has no chrome window associated." + ); + } + + let window = aBrowsingContext.topChromeWindow; + let tabDialogBox = window.gBrowser.getTabDialogBox( + aBrowsingContext.embedderElement + ); + + return tabDialogBox.open( + aDialogURL, + { + features: resizable, + allowDuplicateDialogs: false, + keepOpenSameOriginNav: true, + }, + aDialogArgs + ); + } + + // If we don't have a BrowsingContext, we need to show a standalone window. + let win = Services.ww.openWindow( + null, + aDialogURL, + null, + `chrome,dialog=yes,centerscreen,${resizable}`, + aDialogArgs + ); + + // Wait until window is closed. + return new Promise(resolve => { + win.addEventListener("unload", function onUnload(event) { + if (event.target.location != aDialogURL) { + return; + } + win.removeEventListener("unload", onUnload); + resolve(); + }); + }); + } + + /** + * Update the open-protocol-handler permission for the site which triggered + * the dialog. Sites with this permission may skip this dialog. + * @param {nsIPrincipal} aPrincipal - subject to update the permission for. + * @param {string} aScheme - Scheme of protocol to allow. + * @param {boolean} aAllow - Whether to set / unset the permission. + */ + _updatePermission(aPrincipal, aScheme, aAllow) { + // If enabled, store open-protocol-handler permission for content principals. + if ( + !nsContentDispatchChooser.isPermissionEnabled || + aPrincipal.isSystemPrincipal || + !this._isSupportedPrincipal(aPrincipal) + ) { + return; + } + + let permKey = this._getSkipProtoDialogPermissionKey(aScheme); + if (aAllow) { + Services.perms.addFromPrincipal( + aPrincipal, + permKey, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + } else { + Services.perms.removeFromPrincipal(aPrincipal, permKey); + } + } + + /** + * Determine if we can use a principal to store permissions. + * @param {nsIPrincipal} aPrincipal - Principal to test. + * @returns {boolean} - true if we can store permissions, false otherwise. + */ + _isSupportedPrincipal(aPrincipal) { + return ( + aPrincipal && + ["http", "https", "moz-extension", "file"].some(scheme => + aPrincipal.schemeIs(scheme) + ) + ); + } +} + +nsContentDispatchChooser.prototype.classID = Components.ID( + "e35d5067-95bc-4029-8432-e8f1e431148d" +); +nsContentDispatchChooser.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIContentDispatchChooser", +]); + +XPCOMUtils.defineLazyPreferenceGetter( + nsContentDispatchChooser, + "isPermissionEnabled", + "security.external_protocol_requires_permission", + true +); |