From 5d3c49f41ef1369efe2a9e63b24543e281ae0776 Mon Sep 17 00:00:00 2001 From: "Roger G. Coram" Date: Tue, 11 Oct 2022 17:13:11 +0100 Subject: [PATCH] feat: add Microsoft 365 HTTP API validation (#1194) * feat: add Outlook HTTP API validation - check the validity of Outlook/Office 365 email addresses via the method outlined [here](https://www.trustedsec.com/blog/achieving-passive-user-enumeration-with-onedrive/). - run only via the `--outlook-use-api` flags (defaulting to `false`.) relates #937 * fix: restrict Office 365 domain use `.mail.protection.outlook.com.` for domains backed by Outlook/Office 365. * fix: continue for non-definitive responses from Outlook API if using `--outlook-use-api`, only return immediately in the event of a positive response: negative responses are ambiguous and the process should fall back to subsequent checks. * fix: amend Outlook references update references to "Microsoft 365" to make is more explicit that this pertains to the underlying services, not Outlook addresses. * fix: continue in the event of a ReqwestError allow both failures in the HTTP request and 404 responses to continue. Co-authored-by: Amaury <1293565+amaurym@users.noreply.github.com> --- cli/src/main.rs | 6 +++ core/src/smtp/hotmail.rs | 79 ++++++++++++++++++++++++++++++++++- core/src/smtp/mod.rs | 17 +++++++- core/src/util/input_output.rs | 13 ++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 6e00d92f1..6f5ff550e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -63,6 +63,11 @@ pub struct Cli { #[clap(long, env, default_value = "false", parse(try_from_str))] pub gmail_use_api: bool, + /// For Microsoft 365 email addresses, use OneDrive's API instead of + /// connecting directly to their SMTP servers. + #[clap(long, env, default_value = "false", parse(try_from_str))] + pub microsoft365_use_api: bool, + /// Whether to check if a gravatar image is existing for the given email. #[clap(long, env, default_value = "false", parse(try_from_str))] pub check_gravatar: bool, @@ -87,6 +92,7 @@ async fn main() -> Result<(), Box> { .set_smtp_port(CONF.smtp_port) .set_yahoo_use_api(CONF.yahoo_use_api) .set_gmail_use_api(CONF.gmail_use_api) + .set_microsoft365_use_api(CONF.microsoft365_use_api) .set_check_gravatar(CONF.check_gravatar); if let Some(proxy_host) = &CONF.proxy_host { input.set_proxy(CheckEmailInputProxy { diff --git a/core/src/smtp/hotmail.rs b/core/src/smtp/hotmail.rs index 2819738da..85f74d90e 100644 --- a/core/src/smtp/hotmail.rs +++ b/core/src/smtp/hotmail.rs @@ -23,12 +23,15 @@ use fantoccini::{ ClientBuilder, Locator, }; use futures::TryFutureExt; +use reqwest::Error as ReqwestError; use serde::Serialize; use serde_json::Map; use super::SmtpDetails; -use crate::util::ser_with_display::ser_with_display; use crate::LOG_TARGET; +use crate::{ + smtp::http_api::create_client, util::ser_with_display::ser_with_display, CheckEmailInput, +}; #[derive(Debug, Serialize)] pub enum HotmailError { @@ -36,6 +39,8 @@ pub enum HotmailError { Cmd(CmdError), #[serde(serialize_with = "ser_with_display")] NewSession(NewSessionError), + #[serde(serialize_with = "ser_with_display")] + ReqwestError(ReqwestError), } impl From for HotmailError { @@ -50,6 +55,12 @@ impl From for HotmailError { } } +impl From for HotmailError { + fn from(error: ReqwestError) -> Self { + HotmailError::ReqwestError(error) + } +} + /// Check if a Hotmail/Outlook email exists by connecting to the password /// recovery page https://account.live.com/password/reset using a headless /// browser. Make sure you have a WebDriver server running locally before @@ -140,9 +151,65 @@ pub async fn check_password_recovery( }) } +/// Convert an email address to its corresponding OneDrive URL. +fn get_onedrive_url(email_address: &str) -> String { + let (username, domain) = email_address + .split_once('@') + .expect("Email address syntax already validated."); + let (tenant, _) = domain + .split_once('.') + .expect("Email domain syntax already validated."); + + format!( + "https://{}-my.sharepoint.com/personal/{}_{}/_layouts/15/onedrive.aspx", + tenant, + username.replace('.', "_"), + domain.replace('.', "_"), + ) +} + +/// Use a HTTP request to verify if an Microsoft 365 email address exists. +/// +/// See +/// [this article]() +/// for details on the underlying principles. +/// +/// Note that a positive response from this function is (at present) considered +/// a reliable indicator that an email-address is valid. However, a negative +/// response is ambigious: the email address may or may not be valid but this +/// cannot be determined by the method outlined here. +pub async fn check_microsoft365_api( + to_email: &EmailAddress, + input: &CheckEmailInput, +) -> Result, HotmailError> { + let url = get_onedrive_url(to_email.as_ref()); + + let response = create_client(input, "microsoft365")? + .head(url) + .send() + .await?; + + log::debug!( + target: LOG_TARGET, + "[email={}] microsoft365 response: {:?}", + to_email, + response + ); + + if response.status() == 403 { + Ok(Some(SmtpDetails { + can_connect_smtp: true, + is_deliverable: true, + ..Default::default() + })) + } else { + Ok(None) + } +} + #[cfg(test)] mod tests { - use super::check_password_recovery; + use super::{check_password_recovery, get_onedrive_url}; use async_smtp::EmailAddress; use async_std::prelude::FutureExt; use std::str::FromStr; @@ -193,4 +260,12 @@ mod tests { let f = f1.try_join(f2).await; assert!(f.is_ok(), "{:?}", f); } + + #[test] + fn test_onedrive_url() { + let email_address = "lightmand@acmecomputercompany.com"; + let expected = "https://acmecomputercompany-my.sharepoint.com/personal/lightmand_acmecomputercompany_com/_layouts/15/onedrive.aspx"; + + assert_eq!(expected, get_onedrive_url(email_address)); + } } diff --git a/core/src/smtp/mod.rs b/core/src/smtp/mod.rs index c53adaa1e..e0230a693 100644 --- a/core/src/smtp/mod.rs +++ b/core/src/smtp/mod.rs @@ -29,7 +29,7 @@ use async_smtp::EmailAddress; use serde::{Deserialize, Serialize}; use trust_dns_proto::rr::Name; -use crate::util::input_output::CheckEmailInput; +use crate::{util::input_output::CheckEmailInput, LOG_TARGET}; use connect::check_smtp_with_retry; pub use error::*; @@ -69,6 +69,21 @@ pub async fn check_smtp( .await .map_err(|err| err.into()); } + if input.microsoft365_use_api && host_lowercase.ends_with(".mail.protection.outlook.com.") { + match hotmail::check_microsoft365_api(to_email, input).await { + Ok(Some(smtp_details)) => return Ok(smtp_details), + // Continue in the event of an error/ambiguous result. + Err(err) => { + log::debug!( + target: LOG_TARGET, + "[email={}] microsoft365 error: {:?}", + to_email, + err, + ); + } + _ => {} + } + } #[cfg(feature = "headless")] if let Some(webdriver) = &input.hotmail_use_headless { // The password recovery page do not always work with Microsoft 365 diff --git a/core/src/util/input_output.rs b/core/src/util/input_output.rs index 64c8b9c6f..ac5458487 100644 --- a/core/src/util/input_output.rs +++ b/core/src/util/input_output.rs @@ -95,6 +95,11 @@ pub struct CheckEmailInput { /// /// Defaults to false. pub gmail_use_api: bool, + /// For Microsoft 365 email addresses, use OneDrive's API instead of + /// connecting directly to their SMTP servers. + /// + /// Defaults to false. + pub microsoft365_use_api: bool, // Whether to check if a gravatar image is existing for the given email. // // Defaults to false @@ -132,6 +137,7 @@ impl Default for CheckEmailInput { smtp_timeout: None, yahoo_use_api: true, gmail_use_api: false, + microsoft365_use_api: false, check_gravatar: false, retries: 2, } @@ -247,6 +253,13 @@ impl CheckEmailInput { self } + /// Set whether to use Microsoft 365's OneDrive API or connecting directly + /// to their SMTP servers. Defaults to false. + pub fn set_microsoft365_use_api(&mut self, use_api: bool) -> &mut CheckEmailInput { + self.microsoft365_use_api = use_api; + self + } + /// Whether to check if a gravatar image is existing for the given email. /// Defaults to false. pub fn set_check_gravatar(&mut self, check_gravatar: bool) -> &mut CheckEmailInput {