Files
tubestation/toolkit/mozapps/handling/ContentDispatchChooser.jsm
Gijs Kruitbosch ca2526a3e7 Bug 1678255 - prompt for external protocol links whose loads were also triggered externally, instead of looping forever, r=pbz,nika
This passes around the "are we external" bit of load information a bunch,
such that the external protocol handling code has access to it.

In this bug and bug 1667468, I think ideally I would have used a check
if we're the OS default for a given protocol before continuing. However,
this information is currently unavailable on Linux (bug 1599713), and
worse, I believe is likely to remain unavailable in flatpak and other
such restricted environments (cf. bug 1618094 - we aren't able to find
out anything about protocol handlers from the OS).

So instead, we prompt the user if we are about to open a link passed
to us externally. There is a small chance this will be Breaking People's
Workflows, where I don't know whether anyone relies on Firefox happily
passing these URIs along to the relevant application (more convenient
than doing all the registry/API work yourself in scripts!) or anything
like that. To help with that, there's a pref,
`network.protocol-handler.prompt-from-external`, that can be created and
set to false to avoid prompting in this case.

Differential Revision: https://phabricator.services.mozilla.com/D103967
2021-02-10 23:49:21 +00:00

618 lines
18 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/. */
// 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 gPrefs = {};
XPCOMUtils.defineLazyPreferenceGetter(
gPrefs,
"promptForExternal",
"network.protocol-handler.prompt-from-external",
true
);
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.
* @param {bool} [aTriggeredExternally] - Whether the load came from outside
* this application.
*/
async handleURI(
aHandler,
aURI,
aPrincipal,
aBrowsingContext,
aTriggeredExternally = false
) {
let callerHasPermission = this._hasProtocolHandlerPermission(
aHandler.type,
aPrincipal
);
// Force showing the dialog for links passed from outside the application.
// This avoids infinite loops, see bug 1678255, bug 1667468, etc.
if (aTriggeredExternally && gPrefs.promptForExternal) {
aHandler.alwaysAskBeforeHandling = true;
}
// 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
);