1/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2/* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6const EXPORTED_SYMBOLS = ["fetchConfigFromExchange", "getAddonsList"];
7
8var { AccountCreationUtils } = ChromeUtils.import(
9  "resource:///modules/accountcreation/AccountCreationUtils.jsm"
10);
11var { XPCOMUtils } = ChromeUtils.import(
12  "resource://gre/modules/XPCOMUtils.jsm"
13);
14
15XPCOMUtils.defineLazyModuleGetters(this, {
16  AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm",
17  FetchHTTP: "resource:///modules/accountcreation/FetchHTTP.jsm",
18  GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm",
19  Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm",
20  Services: "resource://gre/modules/Services.jsm",
21  setTimeout: "resource://gre/modules/Timer.jsm",
22});
23
24var {
25  Abortable,
26  assert,
27  ddump,
28  deepCopy,
29  Exception,
30  gAccountSetupLogger,
31  getStringBundle,
32  PriorityOrderAbortable,
33  SuccessiveAbortable,
34  TimeoutAbortable,
35} = AccountCreationUtils;
36
37/**
38 * Tries to get a configuration from an MS Exchange server
39 * using Microsoft AutoDiscover protocol.
40 *
41 * Disclaimers:
42 * - To support domain hosters, we cannot use SSL. That means we
43 *   rely on insecure DNS and http, which means the results may be
44 *   forged when under attack. The same is true for guessConfig(), though.
45 *
46 * @param {string} domain - The domain part of the user's email address
47 * @param {string} emailAddress - The user's email address
48 * @param {string} username - (Optional) The user's login name.
49 *         If null, email address will be used.
50 * @param {string} password - The user's password for that email address
51 * @param {Function(domain, okCallback, cancelCallback)} confirmCallback - A
52 *        callback that will be called to confirm redirection to another domain.
53 * @param {Function(config {AccountConfig})} successCallback - A callback that
54 *         will be called when we could retrieve a configuration.
55 *         The AccountConfig object will be passed in as first parameter.
56 * @param {Function(ex)} errorCallback - A callback that
57 *         will be called when we could not retrieve a configuration,
58 *         for whatever reason. This is expected (e.g. when there's no config
59 *         for this domain at this location),
60 *         so do not unconditionally show this to the user.
61 *         The first parameter will be an exception object or error string.
62 */
63function fetchConfigFromExchange(
64  domain,
65  emailAddress,
66  username,
67  password,
68  confirmCallback,
69  successCallback,
70  errorCallback
71) {
72  assert(typeof successCallback == "function");
73  assert(typeof errorCallback == "function");
74  if (
75    !Services.prefs.getBoolPref(
76      "mailnews.auto_config.fetchFromExchange.enabled",
77      true
78    )
79  ) {
80    errorCallback("Exchange AutoDiscover disabled per user preference");
81    return new Abortable();
82  }
83
84  // <https://technet.microsoft.com/en-us/library/bb124251(v=exchg.160).aspx#Autodiscover%20services%20in%20Outlook>
85  // <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"
86  let url1 =
87    "https://autodiscover." +
88    Sanitizer.hostname(domain) +
89    "/autodiscover/autodiscover.xml";
90  let url2 =
91    "https://" + Sanitizer.hostname(domain) + "/autodiscover/autodiscover.xml";
92  let url3 =
93    "http://autodiscover." +
94    Sanitizer.hostname(domain) +
95    "/autodiscover/autodiscover.xml";
96  let body = `<?xml version="1.0" encoding="utf-8"?>
97    <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
98      <Request>
99        <EMailAddress>${emailAddress}</EMailAddress>
100        <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
101      </Request>
102    </Autodiscover>`;
103  let callArgs = {
104    uploadBody: body,
105    post: true,
106    headers: {
107      // outlook.com needs this exact string, with space and lower case "utf".
108      // Compare bug 1454325 comment 15.
109      "Content-Type": "text/xml; charset=utf-8",
110    },
111    username: username || emailAddress,
112    password,
113    allowAuthPrompt: false,
114  };
115  let call;
116  let fetch;
117  let fetch3;
118
119  let successive = new SuccessiveAbortable();
120  let priority = new PriorityOrderAbortable(function(xml, call) {
121    // success
122    readAutoDiscoverResponse(
123      xml,
124      successive,
125      emailAddress,
126      username,
127      password,
128      confirmCallback,
129      config => {
130        config.subSource = `exchange-from-${call.foundMsg}`;
131        return detectStandardProtocols(config, domain, successCallback);
132      },
133      errorCallback
134    );
135  }, errorCallback); // all failed
136
137  call = priority.addCall();
138  call.foundMsg = "url1";
139  fetch = new FetchHTTP(
140    url1,
141    callArgs,
142    call.successCallback(),
143    call.errorCallback()
144  );
145  fetch.start();
146  call.setAbortable(fetch);
147
148  call = priority.addCall();
149  call.foundMsg = "url2";
150  fetch = new FetchHTTP(
151    url2,
152    callArgs,
153    call.successCallback(),
154    call.errorCallback()
155  );
156  fetch.start();
157  call.setAbortable(fetch);
158
159  call = priority.addCall();
160  call.foundMsg = "url3";
161  let call3ErrorCallback = call.errorCallback();
162  // url3 is HTTP (not HTTPS), so suppress password. Even MS spec demands so.
163  let call3Args = deepCopy(callArgs);
164  delete call3Args.username;
165  delete call3Args.password;
166  fetch3 = new FetchHTTP(url3, call3Args, call.successCallback(), ex => {
167    // url3 is an HTTP URL that will redirect to the real one, usually a
168    // HTTPS URL of the hoster. XMLHttpRequest unfortunately loses the call
169    // parameters, drops the auth, drops the body, and turns POST into GET,
170    // which cause the call to fail. For AutoDiscover mechanism to work,
171    // we need to repeat the call with the correct parameters again.
172    let redirectURL = fetch3._request.responseURL;
173    if (!redirectURL.startsWith("https:")) {
174      call3ErrorCallback(ex);
175      return;
176    }
177    let redirectURI = Services.io.newURI(redirectURL);
178    let redirectDomain = Services.eTLD.getBaseDomain(redirectURI);
179    let originalDomain = Services.eTLD.getBaseDomainFromHost(domain);
180
181    function fetchRedirect() {
182      let fetchCall = priority.addCall();
183      let fetch = new FetchHTTP(
184        redirectURL,
185        callArgs, // now with auth
186        fetchCall.successCallback(),
187        fetchCall.errorCallback()
188      );
189      fetchCall.setAbortable(fetch);
190      fetch.start();
191    }
192
193    const kSafeDomains = ["office365.com", "outlook.com"];
194    if (
195      redirectDomain != originalDomain &&
196      !kSafeDomains.includes(redirectDomain)
197    ) {
198      // Given that we received the redirect URL from an insecure HTTP call,
199      // we ask the user whether he trusts the redirect domain.
200      gAccountSetupLogger.info("AutoDiscover HTTP redirected to other domain");
201      let dialogSuccessive = new SuccessiveAbortable();
202      // Because the dialog implements Abortable, the dialog will cancel and
203      // close automatically, if a slow higher priority call returns late.
204      let dialogCall = priority.addCall();
205      dialogCall.setAbortable(dialogSuccessive);
206      call3ErrorCallback(new Exception("Redirected"));
207      dialogSuccessive.current = new TimeoutAbortable(
208        setTimeout(() => {
209          dialogSuccessive.current = confirmCallback(
210            redirectDomain,
211            () => {
212              // User agreed.
213              fetchRedirect();
214              // Remove the dialog from the call stack.
215              dialogCall.errorCallback()(new Exception("Proceed to fetch"));
216            },
217            ex => {
218              // User rejected, or action cancelled otherwise.
219              dialogCall.errorCallback()(ex);
220            }
221          );
222          // Account for a slow server response.
223          // This will prevent showing the warning message when not necessary.
224          // The timeout is just for optics. The Abortable ensures that it works.
225        }, 2000)
226      );
227    } else {
228      fetchRedirect();
229      call3ErrorCallback(new Exception("Redirected"));
230    }
231  });
232  fetch3.start();
233  call.setAbortable(fetch3);
234
235  successive.current = priority;
236  return successive;
237}
238
239var gLoopCounter = 0;
240
241/**
242 * @param {JXON} xml - The Exchange server AutoDiscover response
243 * @param {Function(config {AccountConfig})} successCallback - @see accountConfig.js
244 */
245function readAutoDiscoverResponse(
246  autoDiscoverXML,
247  successive,
248  emailAddress,
249  username,
250  password,
251  confirmCallback,
252  successCallback,
253  errorCallback
254) {
255  assert(successive instanceof SuccessiveAbortable);
256  assert(typeof successCallback == "function");
257  assert(typeof errorCallback == "function");
258
259  // redirect to other email address
260  if (
261    "Account" in autoDiscoverXML.Autodiscover.Response &&
262    "RedirectAddr" in autoDiscoverXML.Autodiscover.Response.Account
263  ) {
264    // <https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/49083e77-8dc2-4010-85c6-f40e090f3b17>
265    let redirectEmailAddress = Sanitizer.emailAddress(
266      autoDiscoverXML.Autodiscover.Response.Account.RedirectAddr
267    );
268    let domain = redirectEmailAddress.split("@").pop();
269    if (++gLoopCounter > 2) {
270      throw new Error("Too many redirects in XML response; domain=" + domain);
271    }
272    successive.current = fetchConfigFromExchange(
273      domain,
274      redirectEmailAddress,
275      // Per spec, need to authenticate with the original email address,
276      // not the redirected address (if not already overridden).
277      username || emailAddress,
278      password,
279      confirmCallback,
280      successCallback,
281      errorCallback
282    );
283    return;
284  }
285
286  let config = readAutoDiscoverXML(autoDiscoverXML, username);
287  if (config.isComplete()) {
288    successCallback(config);
289  } else {
290    errorCallback(new Exception("No valid configs found in AutoDiscover XML"));
291  }
292}
293
294/* eslint-disable complexity */
295/**
296 * @param {JXON} xml - The Exchange server AutoDiscover response
297 * @param {string} username - (Optional) The user's login name
298 *     If null, email address placeholder will be used.
299 * @returns {AccountConfig} - @see accountConfig.js
300 *
301 * @see <https://www.msxfaq.de/exchange/autodiscover/autodiscover_xml.htm>
302 */
303function readAutoDiscoverXML(autoDiscoverXML, username) {
304  if (
305    typeof autoDiscoverXML != "object" ||
306    !("Autodiscover" in autoDiscoverXML) ||
307    !("Response" in autoDiscoverXML.Autodiscover) ||
308    !("Account" in autoDiscoverXML.Autodiscover.Response) ||
309    !("Protocol" in autoDiscoverXML.Autodiscover.Response.Account)
310  ) {
311    let stringBundle = getStringBundle(
312      "chrome://messenger/locale/accountCreationModel.properties"
313    );
314    throw new Exception(
315      stringBundle.GetStringFromName("no_autodiscover.error")
316    );
317  }
318  var xml = autoDiscoverXML.Autodiscover.Response.Account;
319
320  function array_or_undef(value) {
321    return value === undefined ? [] : value;
322  }
323
324  var config = new AccountConfig();
325  config.source = AccountConfig.kSourceExchange;
326  config.incoming.username = username || "%EMAILADDRESS%";
327  config.incoming.socketType = 2; // only https supported
328  config.incoming.port = 443;
329  config.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
330  config.incoming.authAlternatives = [Ci.nsMsgAuthMethod.OAuth2];
331  config.outgoing.addThisServer = false;
332  config.outgoing.useGlobalPreferredServer = true;
333
334  for (let protocolX of array_or_undef(xml.$Protocol)) {
335    try {
336      let type = Sanitizer.enum(
337        protocolX.Type,
338        ["WEB", "EXHTTP", "EXCH", "EXPR", "POP3", "IMAP", "SMTP"],
339        "unknown"
340      );
341      if (type == "WEB") {
342        let urlsX;
343        if ("External" in protocolX) {
344          urlsX = protocolX.External;
345        } else if ("Internal" in protocolX) {
346          urlsX = protocolX.Internal;
347        }
348        if (urlsX) {
349          config.incoming.owaURL = Sanitizer.url(urlsX.OWAUrl.value);
350          if (
351            !config.incoming.ewsURL &&
352            "Protocol" in urlsX &&
353            "ASUrl" in urlsX.Protocol
354          ) {
355            config.incoming.ewsURL = Sanitizer.url(urlsX.Protocol.ASUrl);
356          }
357          config.incoming.type = "exchange";
358          let parsedURL = new URL(config.incoming.owaURL);
359          config.incoming.hostname = Sanitizer.hostname(parsedURL.hostname);
360          if (parsedURL.port) {
361            config.incoming.port = Sanitizer.integer(parsedURL.port);
362          }
363        }
364      } else if (type == "EXHTTP" || type == "EXCH") {
365        config.incoming.ewsURL = Sanitizer.url(protocolX.EwsUrl);
366        if (!config.incoming.ewsURL) {
367          config.incoming.ewsURL = Sanitizer.url(protocolX.ASUrl);
368        }
369        config.incoming.type = "exchange";
370        let parsedURL = new URL(config.incoming.ewsURL);
371        config.incoming.hostname = Sanitizer.hostname(parsedURL.hostname);
372        if (parsedURL.port) {
373          config.incoming.port = Sanitizer.integer(parsedURL.port);
374        }
375      } else if (type == "POP3" || type == "IMAP" || type == "SMTP") {
376        let server;
377        if (type == "SMTP") {
378          server = config.createNewOutgoing();
379        } else {
380          server = config.createNewIncoming();
381        }
382
383        server.type = Sanitizer.translate(type, {
384          POP3: "pop3",
385          IMAP: "imap",
386          SMTP: "smtp",
387        });
388        server.hostname = Sanitizer.hostname(protocolX.Server);
389        server.port = Sanitizer.integer(protocolX.Port);
390        server.socketType = 1; // plain
391        if (
392          "SSL" in protocolX &&
393          protocolX.SSL.toLowerCase() == "on" // "On" or "Off"
394        ) {
395          // SSL is too unspecific. Do they mean STARTTLS or normal TLS?
396          // For now, assume normal TLS, unless it's a standard plain port.
397          switch (server.port) {
398            case 143: // IMAP standard
399            case 110: // POP3 standard
400            case 25: // SMTP standard
401            case 587: // SMTP standard
402              server.socketType = 3; // STARTTLS
403              break;
404            case 993: // IMAP SSL
405            case 995: // POP3 SSL
406            case 465: // SMTP SSL
407            default:
408              // if non-standard port, assume normal TLS, not STARTTLS
409              server.socketType = 2; // normal TLS
410              break;
411          }
412        }
413        server.auth = Ci.nsMsgAuthMethod.passwordCleartext;
414        if (
415          "SPA" in protocolX &&
416          protocolX.SPA.toLowerCase() == "on" // "On" or "Off"
417        ) {
418          // Secure Password Authentication = NTLM or GSSAPI/Kerberos
419          server.auth = Ci.nsMsgAuthMethod.secure;
420        }
421        if ("LoginName" in protocolX) {
422          server.username = Sanitizer.nonemptystring(protocolX.LoginName);
423        } else {
424          server.username = username || "%EMAILADDRESS%";
425        }
426
427        if (type == "SMTP") {
428          if (!config.outgoing.hostname) {
429            config.outgoing = server;
430          } else {
431            config.outgoingAlternatives.push(server);
432          }
433        } else if (!config.incoming.hostname) {
434          // eslint-disable-line no-lonely-if
435          config.incoming = server;
436        } else {
437          config.incomingAlternatives.push(server);
438        }
439      }
440
441      // else unknown or unsupported protocol
442    } catch (e) {
443      Cu.reportError(e);
444    }
445  }
446
447  // OAuth2 settings, so that createInBackend() doesn't bail out
448  if (config.incoming.owaURL || config.incoming.ewsURL) {
449    config.incoming.oauthSettings = {
450      issuer: config.incoming.hostname,
451      scope: config.incoming.owaURL || config.incoming.ewsURL,
452    };
453    config.outgoing.oauthSettings = {
454      issuer: config.incoming.hostname,
455      scope: config.incoming.owaURL || config.incoming.ewsURL,
456    };
457  }
458
459  return config;
460}
461/* eslint-enable complexity */
462
463/**
464 * Ask server which addons can handle this config.
465 * @param {AccountConfig} config
466 * @param {Function(config {AccountConfig})} successCallback
467 * @returns {Abortable}
468 */
469function getAddonsList(config, successCallback, errorCallback) {
470  let incoming = [config.incoming, ...config.incomingAlternatives].find(
471    alt => alt.type == "exchange"
472  );
473  if (!incoming) {
474    successCallback();
475    return new Abortable();
476  }
477  let url = Services.prefs.getCharPref("mailnews.auto_config.addons_url");
478  if (!url) {
479    errorCallback(new Exception("no URL for addons list configured"));
480    return new Abortable();
481  }
482  let fetch = new FetchHTTP(
483    url,
484    { allowCache: true, timeout: 10000 },
485    function(json) {
486      let addons = readAddonsJSON(json);
487      addons = addons.filter(addon => {
488        // Find types matching the current config.
489        // Pick the first in the list as the preferred one and
490        // tell the UI to use that one.
491        addon.useType = addon.supportedTypes.find(
492          type =>
493            (incoming.owaURL && type.protocolType == "owa") ||
494            (incoming.ewsURL && type.protocolType == "ews") ||
495            (incoming.easURL && type.protocolType == "eas")
496        );
497        return !!addon.useType;
498      });
499      if (addons.length == 0) {
500        errorCallback(
501          new Exception(
502            "Config found, but no addons known to handle the config"
503          )
504        );
505        return;
506      }
507      config.addons = addons;
508      successCallback(config);
509    },
510    errorCallback
511  );
512  fetch.start();
513  return fetch;
514}
515
516/**
517 * This reads the addons list JSON and makes security validations,
518 * e.g. that the URLs are not chrome: URLs, which could lead to exploits.
519 * It also chooses the right language etc..
520 *
521 * @param {JSON} json - the addons.json file contents
522 * @returns {Array of AddonInfo} - @see AccountConfig.addons
523 *
524 * accountTypes are listed in order of decreasing preference.
525 * Languages are 2-letter codes. If a language is not available,
526 * the first name or description will be used.
527 *
528 * Parse e.g.
529[
530  {
531    "id": "owl@beonex.com",
532    "name": {
533      "en": "Owl",
534      "de": "Eule"
535    },
536    "description": {
537      "en": "Owl is a paid third-party addon that allows you to access your email account on Exchange servers. See the website for prices.",
538      "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."
539    },
540    "minVersion": "0.2",
541    "xpiURL": "http://www.beonex.com/owl/latest.xpi",
542    "websiteURL": "http://www.beonex.com/owl/",
543    "icon32": "http://www.beonex.com/owl/owl-32.png",
544    "accountTypes": [
545      {
546        "generalType": "exchange",
547        "protocolType": "owa",
548        "addonAccountType": "owl-owa"
549      },
550      {
551        "generalType": "exchange",
552        "protocolType": "eas",
553        "addonAccountType": "owl-eas"
554      }
555    ]
556  }
557]
558 */
559function readAddonsJSON(json) {
560  let addons = [];
561  function ensureArray(value) {
562    return Array.isArray(value) ? value : [];
563  }
564  let xulLocale = Services.locale.requestedLocale;
565  let locale = xulLocale ? xulLocale.substring(0, 5) : "default";
566  for (let addonJSON of ensureArray(json)) {
567    try {
568      let addon = {
569        id: addonJSON.id,
570        minVersion: addonJSON.minVersion,
571        xpiURL: Sanitizer.url(addonJSON.xpiURL),
572        websiteURL: Sanitizer.url(addonJSON.websiteURL),
573        icon32: addonJSON.icon32 ? Sanitizer.url(addonJSON.icon32) : null,
574        supportedTypes: [],
575      };
576      assert(
577        new URL(addon.xpiURL).protocol == "https:",
578        "XPI download URL needs to be https"
579      );
580      addon.name =
581        locale in addonJSON.name ? addonJSON.name[locale] : addonJSON.name[0];
582      addon.description =
583        locale in addonJSON.description
584          ? addonJSON.description[locale]
585          : addonJSON.description[0];
586      for (let typeJSON of ensureArray(addonJSON.accountTypes)) {
587        try {
588          addon.supportedTypes.push({
589            generalType: Sanitizer.alphanumdash(typeJSON.generalType),
590            protocolType: Sanitizer.alphanumdash(typeJSON.protocolType),
591            addonAccountType: Sanitizer.alphanumdash(typeJSON.addonAccountType),
592          });
593        } catch (e) {
594          ddump(e);
595        }
596      }
597      addons.push(addon);
598    } catch (e) {
599      ddump(e);
600    }
601  }
602  return addons;
603}
604
605/**
606 * Probe a found Exchange server for IMAP/POP3 and SMTP support.
607 *
608 * @param {AccountConfig} config - The initial detected Exchange configuration.
609 * @param {string} domain - The domain part of the user's email address
610 * @param {Function(config {AccountConfig})} successCallback - A callback that
611 *   will be called when we found an appropriate configuration.
612 *   The AccountConfig object will be passed in as first parameter.
613 */
614function detectStandardProtocols(config, domain, successCallback) {
615  gAccountSetupLogger.info("Exchange Autodiscover gave some results.");
616  let alts = [config.incoming, ...config.incomingAlternatives];
617  if (alts.find(alt => alt.type == "imap" || alt.type == "pop3")) {
618    // Autodiscover found an exchange server with advertized IMAP and/or
619    // POP3 support. We're done then.
620    config.preferStandardProtocols();
621    successCallback(config);
622    return;
623  }
624
625  // Autodiscover is known not to advertise all that it supports. Let's see
626  // if there really isn't any IMAP/POP3 support by probing the Exchange
627  // server. Use the server hostname already found.
628  let config2 = new AccountConfig();
629  config2.incoming.hostname = config.incoming.hostname;
630  config2.incoming.username = config.incoming.username || "%EMAILADDRESS%";
631  // For Exchange 2013+ Kerberos/GSSAPI and NTLM options do not work by
632  // default at least for Linux users, even if support is detected.
633  config2.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext;
634
635  config2.outgoing.hostname = config.incoming.hostname;
636  config2.outgoing.username = config.incoming.username || "%EMAILADDRESS%";
637
638  config2.incomingAlternatives = config.incomingAlternatives;
639  config2.incomingAlternatives.push(config.incoming); // type=exchange
640
641  config2.outgoingAlternatives = config.outgoingAlternatives;
642  if (config.outgoing.hostname) {
643    config2.outgoingAlternatives.push(config.outgoing);
644  }
645
646  GuessConfig.guessConfig(
647    domain,
648    function(type, hostname, port, ssl, done, config) {
649      gAccountSetupLogger.info(
650        `Probing exchange server ${hostname} for ${type} protocol support.`
651      );
652    },
653    function(probedConfig) {
654      // Probing succeeded: found open protocols, yay!
655      successCallback(probedConfig);
656    },
657    function(e, probedConfig) {
658      // Probing didn't find any open protocols.
659      // Let's use the exchange (only) config that was listed then.
660      config.subSource += "-guess";
661      successCallback(config);
662    },
663    config2,
664    "both"
665  );
666}
667