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