diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm | 676 |
1 files changed, 676 insertions, 0 deletions
diff --git a/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm new file mode 100644 index 0000000000..5813aa0240 --- /dev/null +++ b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm @@ -0,0 +1,676 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["fetchConfigFromExchange", "getAddonsList"]; + +var { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm", + FetchHTTP: "resource:///modules/accountcreation/FetchHTTP.jsm", + GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm", + Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm", +}); + +var { + Abortable, + assert, + ddump, + deepCopy, + Exception, + gAccountSetupLogger, + getStringBundle, + PriorityOrderAbortable, + SuccessiveAbortable, + TimeoutAbortable, +} = AccountCreationUtils; + +/** + * Tries to get a configuration from an MS Exchange server + * using Microsoft AutoDiscover protocol. + * + * Disclaimers: + * - To support domain hosters, we cannot use SSL. That means we + * rely on insecure DNS and http, which means the results may be + * forged when under attack. The same is true for guessConfig(), though. + * + * @param {string} domain - The domain part of the user's email address + * @param {string} emailAddress - The user's email address + * @param {string} username - (Optional) The user's login name. + * If null, email address will be used. + * @param {string} password - The user's password for that email address + * @param {Function(domain, okCallback, cancelCallback)} confirmCallback - A + * callback that will be called to confirm redirection to another domain. + * @param {Function(config {AccountConfig})} successCallback - A callback that + * will be called when we could retrieve a configuration. + * The AccountConfig object will be passed in as first parameter. + * @param {Function(ex)} errorCallback - A callback that + * will be called when we could not retrieve a configuration, + * for whatever reason. This is expected (e.g. when there's no config + * for this domain at this location), + * so do not unconditionally show this to the user. + * The first parameter will be an exception object or error string. + */ +function fetchConfigFromExchange( + domain, + emailAddress, + username, + password, + confirmCallback, + successCallback, + errorCallback +) { + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + if ( + !Services.prefs.getBoolPref( + "mailnews.auto_config.fetchFromExchange.enabled", + true + ) + ) { + errorCallback("Exchange AutoDiscover disabled per user preference"); + return new Abortable(); + } + + // <https://technet.microsoft.com/en-us/library/bb124251(v=exchg.160).aspx#Autodiscover%20services%20in%20Outlook> + // <https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638(v%3Dexchg.140)>, search for "The Autodiscover service uses one of these four methods" + let url1 = + "https://autodiscover." + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let url2 = + "https://" + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let url3 = + "http://autodiscover." + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let body = `<?xml version="1.0" encoding="utf-8"?> + <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"> + <Request> + <EMailAddress>${emailAddress}</EMailAddress> + <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema> + </Request> + </Autodiscover>`; + let callArgs = { + uploadBody: body, + post: true, + headers: { + // outlook.com needs this exact string, with space and lower case "utf". + // Compare bug 1454325 comment 15. + "Content-Type": "text/xml; charset=utf-8", + }, + username: username || emailAddress, + password, + allowAuthPrompt: false, + }; + let call; + let fetch; + let fetch3; + + let successive = new SuccessiveAbortable(); + let priority = new PriorityOrderAbortable(function (xml, call) { + // success + readAutoDiscoverResponse( + xml, + successive, + emailAddress, + username, + password, + confirmCallback, + config => { + config.subSource = `exchange-from-${call.foundMsg}`; + return detectStandardProtocols(config, domain, successCallback); + }, + errorCallback + ); + }, errorCallback); // all failed + + call = priority.addCall(); + call.foundMsg = "url1"; + fetch = new lazy.FetchHTTP( + url1, + callArgs, + call.successCallback(), + call.errorCallback() + ); + fetch.start(); + call.setAbortable(fetch); + + call = priority.addCall(); + call.foundMsg = "url2"; + fetch = new lazy.FetchHTTP( + url2, + callArgs, + call.successCallback(), + call.errorCallback() + ); + fetch.start(); + call.setAbortable(fetch); + + call = priority.addCall(); + call.foundMsg = "url3"; + let call3ErrorCallback = call.errorCallback(); + // url3 is HTTP (not HTTPS), so suppress password. Even MS spec demands so. + let call3Args = deepCopy(callArgs); + delete call3Args.username; + delete call3Args.password; + fetch3 = new lazy.FetchHTTP(url3, call3Args, call.successCallback(), ex => { + // url3 is an HTTP URL that will redirect to the real one, usually a + // HTTPS URL of the hoster. XMLHttpRequest unfortunately loses the call + // parameters, drops the auth, drops the body, and turns POST into GET, + // which cause the call to fail. For AutoDiscover mechanism to work, + // we need to repeat the call with the correct parameters again. + let redirectURL = fetch3._request.responseURL; + if (!redirectURL.startsWith("https:")) { + call3ErrorCallback(ex); + return; + } + let redirectURI = Services.io.newURI(redirectURL); + let redirectDomain = Services.eTLD.getBaseDomain(redirectURI); + let originalDomain = Services.eTLD.getBaseDomainFromHost(domain); + + function fetchRedirect() { + let fetchCall = priority.addCall(); + let fetch = new lazy.FetchHTTP( + redirectURL, + callArgs, // now with auth + fetchCall.successCallback(), + fetchCall.errorCallback() + ); + fetchCall.setAbortable(fetch); + fetch.start(); + } + + const kSafeDomains = ["office365.com", "outlook.com"]; + if ( + redirectDomain != originalDomain && + !kSafeDomains.includes(redirectDomain) + ) { + // Given that we received the redirect URL from an insecure HTTP call, + // we ask the user whether he trusts the redirect domain. + gAccountSetupLogger.info("AutoDiscover HTTP redirected to other domain"); + let dialogSuccessive = new SuccessiveAbortable(); + // Because the dialog implements Abortable, the dialog will cancel and + // close automatically, if a slow higher priority call returns late. + let dialogCall = priority.addCall(); + dialogCall.setAbortable(dialogSuccessive); + call3ErrorCallback(new Exception("Redirected")); + dialogSuccessive.current = new TimeoutAbortable( + lazy.setTimeout(() => { + dialogSuccessive.current = confirmCallback( + redirectDomain, + () => { + // User agreed. + fetchRedirect(); + // Remove the dialog from the call stack. + dialogCall.errorCallback()(new Exception("Proceed to fetch")); + }, + ex => { + // User rejected, or action cancelled otherwise. + dialogCall.errorCallback()(ex); + } + ); + // Account for a slow server response. + // This will prevent showing the warning message when not necessary. + // The timeout is just for optics. The Abortable ensures that it works. + }, 2000) + ); + } else { + fetchRedirect(); + call3ErrorCallback(new Exception("Redirected")); + } + }); + fetch3.start(); + call.setAbortable(fetch3); + + successive.current = priority; + return successive; +} + +var gLoopCounter = 0; + +/** + * @param {JXON} xml - The Exchange server AutoDiscover response + * @param {Function(config {AccountConfig})} successCallback - @see accountConfig.js + */ +function readAutoDiscoverResponse( + autoDiscoverXML, + successive, + emailAddress, + username, + password, + confirmCallback, + successCallback, + errorCallback +) { + assert(successive instanceof SuccessiveAbortable); + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + + // redirect to other email address + if ( + "Account" in autoDiscoverXML.Autodiscover.Response && + "RedirectAddr" in autoDiscoverXML.Autodiscover.Response.Account + ) { + // <https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/49083e77-8dc2-4010-85c6-f40e090f3b17> + let redirectEmailAddress = lazy.Sanitizer.emailAddress( + autoDiscoverXML.Autodiscover.Response.Account.RedirectAddr + ); + let domain = redirectEmailAddress.split("@").pop(); + if (++gLoopCounter > 2) { + throw new Error("Too many redirects in XML response; domain=" + domain); + } + successive.current = fetchConfigFromExchange( + domain, + redirectEmailAddress, + // Per spec, need to authenticate with the original email address, + // not the redirected address (if not already overridden). + username || emailAddress, + password, + confirmCallback, + successCallback, + errorCallback + ); + return; + } + + let config = readAutoDiscoverXML(autoDiscoverXML, username); + if (config.isComplete()) { + successCallback(config); + } else { + errorCallback(new Exception("No valid configs found in AutoDiscover XML")); + } +} + +/* eslint-disable complexity */ +/** + * @param {JXON} xml - The Exchange server AutoDiscover response + * @param {string} username - (Optional) The user's login name + * If null, email address placeholder will be used. + * @returns {AccountConfig} - @see accountConfig.js + * + * @see <https://www.msxfaq.de/exchange/autodiscover/autodiscover_xml.htm> + */ +function readAutoDiscoverXML(autoDiscoverXML, username) { + if ( + typeof autoDiscoverXML != "object" || + !("Autodiscover" in autoDiscoverXML) || + !("Response" in autoDiscoverXML.Autodiscover) || + !("Account" in autoDiscoverXML.Autodiscover.Response) || + !("Protocol" in autoDiscoverXML.Autodiscover.Response.Account) + ) { + let stringBundle = getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ); + throw new Exception( + stringBundle.GetStringFromName("no_autodiscover.error") + ); + } + var xml = autoDiscoverXML.Autodiscover.Response.Account; + + function array_or_undef(value) { + return value === undefined ? [] : value; + } + + var config = new lazy.AccountConfig(); + config.source = lazy.AccountConfig.kSourceExchange; + config.incoming.username = username || "%EMAILADDRESS%"; + config.incoming.socketType = Ci.nsMsgSocketType.SSL; // only https supported + config.incoming.port = 443; + config.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext; + config.incoming.authAlternatives = [Ci.nsMsgAuthMethod.OAuth2]; + config.outgoing.addThisServer = false; + config.outgoing.useGlobalPreferredServer = true; + + for (let protocolX of array_or_undef(xml.$Protocol)) { + try { + let type = lazy.Sanitizer.enum( + protocolX.Type, + ["WEB", "EXHTTP", "EXCH", "EXPR", "POP3", "IMAP", "SMTP"], + "unknown" + ); + if (type == "WEB") { + let urlsX; + if ("External" in protocolX) { + urlsX = protocolX.External; + } else if ("Internal" in protocolX) { + urlsX = protocolX.Internal; + } + if (urlsX) { + config.incoming.owaURL = lazy.Sanitizer.url(urlsX.OWAUrl.value); + if ( + !config.incoming.ewsURL && + "Protocol" in urlsX && + "ASUrl" in urlsX.Protocol + ) { + config.incoming.ewsURL = lazy.Sanitizer.url(urlsX.Protocol.ASUrl); + } + config.incoming.type = "exchange"; + let parsedURL = new URL(config.incoming.owaURL); + config.incoming.hostname = lazy.Sanitizer.hostname( + parsedURL.hostname + ); + if (parsedURL.port) { + config.incoming.port = lazy.Sanitizer.integer(parsedURL.port); + } + } + } else if (type == "EXHTTP" || type == "EXCH") { + config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.EwsUrl); + if (!config.incoming.ewsURL) { + config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.ASUrl); + } + config.incoming.type = "exchange"; + let parsedURL = new URL(config.incoming.ewsURL); + config.incoming.hostname = lazy.Sanitizer.hostname(parsedURL.hostname); + if (parsedURL.port) { + config.incoming.port = lazy.Sanitizer.integer(parsedURL.port); + } + } else if (type == "POP3" || type == "IMAP" || type == "SMTP") { + let server; + if (type == "SMTP") { + server = config.createNewOutgoing(); + } else { + server = config.createNewIncoming(); + } + + server.type = lazy.Sanitizer.translate(type, { + POP3: "pop3", + IMAP: "imap", + SMTP: "smtp", + }); + server.hostname = lazy.Sanitizer.hostname(protocolX.Server); + server.port = lazy.Sanitizer.integer(protocolX.Port); + server.socketType = Ci.nsMsgSocketType.plain; + if ( + "SSL" in protocolX && + protocolX.SSL.toLowerCase() == "on" // "On" or "Off" + ) { + // SSL is too unspecific. Do they mean STARTTLS or normal TLS? + // For now, assume normal TLS, unless it's a standard plain port. + switch (server.port) { + case 143: // IMAP standard + case 110: // POP3 standard + case 25: // SMTP standard + case 587: // SMTP standard + server.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS; + break; + case 993: // IMAP SSL + case 995: // POP3 SSL + case 465: // SMTP SSL + default: + // if non-standard port, assume normal TLS, not STARTTLS + server.socketType = Ci.nsMsgSocketType.SSL; + break; + } + } + server.auth = Ci.nsMsgAuthMethod.passwordCleartext; + if ( + "SPA" in protocolX && + protocolX.SPA.toLowerCase() == "on" // "On" or "Off" + ) { + // Secure Password Authentication = NTLM or GSSAPI/Kerberos + server.auth = Ci.nsMsgAuthMethod.secure; + } + if ("LoginName" in protocolX) { + server.username = lazy.Sanitizer.nonemptystring(protocolX.LoginName); + } else { + server.username = username || "%EMAILADDRESS%"; + } + + if (type == "SMTP") { + if (!config.outgoing.hostname) { + config.outgoing = server; + } else { + config.outgoingAlternatives.push(server); + } + } else if (!config.incoming.hostname) { + // eslint-disable-line no-lonely-if + config.incoming = server; + } else { + config.incomingAlternatives.push(server); + } + } + + // else unknown or unsupported protocol + } catch (e) { + console.error(e); + } + } + + // OAuth2 settings, so that createInBackend() doesn't bail out + if (config.incoming.owaURL || config.incoming.ewsURL) { + config.incoming.oauthSettings = { + issuer: config.incoming.hostname, + scope: config.incoming.owaURL || config.incoming.ewsURL, + }; + config.outgoing.oauthSettings = { + issuer: config.incoming.hostname, + scope: config.incoming.owaURL || config.incoming.ewsURL, + }; + } + + return config; +} +/* eslint-enable complexity */ + +/** + * Ask server which addons can handle this config. + * + * @param {AccountConfig} config + * @param {Function(config {AccountConfig})} successCallback + * @returns {Abortable} + */ +function getAddonsList(config, successCallback, errorCallback) { + let incoming = [config.incoming, ...config.incomingAlternatives].find( + alt => alt.type == "exchange" + ); + if (!incoming) { + successCallback(); + return new Abortable(); + } + let url = Services.prefs.getCharPref("mailnews.auto_config.addons_url"); + if (!url) { + errorCallback(new Exception("no URL for addons list configured")); + return new Abortable(); + } + let fetch = new lazy.FetchHTTP( + url, + { allowCache: true, timeout: 10000 }, + function (json) { + let addons = readAddonsJSON(json); + addons = addons.filter(addon => { + // Find types matching the current config. + // Pick the first in the list as the preferred one and + // tell the UI to use that one. + addon.useType = addon.supportedTypes.find( + type => + (incoming.owaURL && type.protocolType == "owa") || + (incoming.ewsURL && type.protocolType == "ews") || + (incoming.easURL && type.protocolType == "eas") + ); + return !!addon.useType; + }); + if (addons.length == 0) { + errorCallback( + new Exception( + "Config found, but no addons known to handle the config" + ) + ); + return; + } + config.addons = addons; + successCallback(config); + }, + errorCallback + ); + fetch.start(); + return fetch; +} + +/** + * This reads the addons list JSON and makes security validations, + * e.g. that the URLs are not chrome: URLs, which could lead to exploits. + * It also chooses the right language etc.. + * + * @param {JSON} json - the addons.json file contents + * @returns {Array of AddonInfo} - @see AccountConfig.addons + * + * accountTypes are listed in order of decreasing preference. + * Languages are 2-letter codes. If a language is not available, + * the first name or description will be used. + * + * Parse e.g. +[ + { + "id": "owl@beonex.com", + "name": { + "en": "Owl", + "de": "Eule" + }, + "description": { + "en": "Owl is a paid third-party addon that allows you to access your email account on Exchange servers. See the website for prices.", + "de": "Eule ist eine Erweiterung von einem Drittanbieter, die Ihnen erlaubt, Exchange-Server zu benutzen. Sie ist kostenpflichtig. Die Preise finden Sie auf der Website." + }, + "minVersion": "0.2", + "xpiURL": "http://www.beonex.com/owl/latest.xpi", + "websiteURL": "http://www.beonex.com/owl/", + "icon32": "http://www.beonex.com/owl/owl-32.png", + "accountTypes": [ + { + "generalType": "exchange", + "protocolType": "owa", + "addonAccountType": "owl-owa" + }, + { + "generalType": "exchange", + "protocolType": "eas", + "addonAccountType": "owl-eas" + } + ] + } +] + */ +function readAddonsJSON(json) { + let addons = []; + function ensureArray(value) { + return Array.isArray(value) ? value : []; + } + let xulLocale = Services.locale.requestedLocale; + let locale = xulLocale ? xulLocale.substring(0, 5) : "default"; + for (let addonJSON of ensureArray(json)) { + try { + let addon = { + id: addonJSON.id, + minVersion: addonJSON.minVersion, + xpiURL: lazy.Sanitizer.url(addonJSON.xpiURL), + websiteURL: lazy.Sanitizer.url(addonJSON.websiteURL), + icon32: addonJSON.icon32 ? lazy.Sanitizer.url(addonJSON.icon32) : null, + supportedTypes: [], + }; + assert( + new URL(addon.xpiURL).protocol == "https:", + "XPI download URL needs to be https" + ); + addon.name = + locale in addonJSON.name ? addonJSON.name[locale] : addonJSON.name[0]; + addon.description = + locale in addonJSON.description + ? addonJSON.description[locale] + : addonJSON.description[0]; + for (let typeJSON of ensureArray(addonJSON.accountTypes)) { + try { + addon.supportedTypes.push({ + generalType: lazy.Sanitizer.alphanumdash(typeJSON.generalType), + protocolType: lazy.Sanitizer.alphanumdash(typeJSON.protocolType), + addonAccountType: lazy.Sanitizer.alphanumdash( + typeJSON.addonAccountType + ), + }); + } catch (e) { + ddump(e); + } + } + addons.push(addon); + } catch (e) { + ddump(e); + } + } + return addons; +} + +/** + * Probe a found Exchange server for IMAP/POP3 and SMTP support. + * + * @param {AccountConfig} config - The initial detected Exchange configuration. + * @param {string} domain - The domain part of the user's email address + * @param {Function(config {AccountConfig})} successCallback - A callback that + * will be called when we found an appropriate configuration. + * The AccountConfig object will be passed in as first parameter. + */ +function detectStandardProtocols(config, domain, successCallback) { + gAccountSetupLogger.info("Exchange Autodiscover gave some results."); + let alts = [config.incoming, ...config.incomingAlternatives]; + if (alts.find(alt => alt.type == "imap" || alt.type == "pop3")) { + // Autodiscover found an exchange server with advertized IMAP and/or + // POP3 support. We're done then. + config.preferStandardProtocols(); + successCallback(config); + return; + } + + // Autodiscover is known not to advertise all that it supports. Let's see + // if there really isn't any IMAP/POP3 support by probing the Exchange + // server. Use the server hostname already found. + let config2 = new lazy.AccountConfig(); + config2.incoming.hostname = config.incoming.hostname; + config2.incoming.username = config.incoming.username || "%EMAILADDRESS%"; + // For Exchange 2013+ Kerberos/GSSAPI and NTLM options do not work by + // default at least for Linux users, even if support is detected. + config2.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext; + + config2.outgoing.hostname = config.incoming.hostname; + config2.outgoing.username = config.incoming.username || "%EMAILADDRESS%"; + + config2.incomingAlternatives = config.incomingAlternatives; + config2.incomingAlternatives.push(config.incoming); // type=exchange + + config2.outgoingAlternatives = config.outgoingAlternatives; + if (config.outgoing.hostname) { + config2.outgoingAlternatives.push(config.outgoing); + } + + lazy.GuessConfig.guessConfig( + domain, + function (type, hostname, port, ssl, done, config) { + gAccountSetupLogger.info( + `Probing exchange server ${hostname} for ${type} protocol support.` + ); + }, + function (probedConfig) { + // Probing succeeded: found open protocols, yay! + successCallback(probedConfig); + }, + function (e, probedConfig) { + // Probing didn't find any open protocols. + // Let's use the exchange (only) config that was listed then. + config.subSource += "-guess"; + successCallback(config); + }, + config2, + "both" + ); +} |