Skip to content

Commit

Permalink
feat: add Microsoft 365 HTTP API validation (#1194)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
PsypherPunk and amaury1093 authored Oct 11, 2022
1 parent 31146d8 commit 5d3c49f
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 3 deletions.
6 changes: 6 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -87,6 +92,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.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 {
Expand Down
79 changes: 77 additions & 2 deletions core/src/smtp/hotmail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,24 @@ 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 {
#[serde(serialize_with = "ser_with_display")]
Cmd(CmdError),
#[serde(serialize_with = "ser_with_display")]
NewSession(NewSessionError),
#[serde(serialize_with = "ser_with_display")]
ReqwestError(ReqwestError),
}

impl From<CmdError> for HotmailError {
Expand All @@ -50,6 +55,12 @@ impl From<NewSessionError> for HotmailError {
}
}

impl From<ReqwestError> 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
Expand Down Expand Up @@ -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](<https://www.trustedsec.com/blog/achieving-passive-user-enumeration-with-onedrive/>)
/// 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<Option<SmtpDetails>, 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;
Expand Down Expand Up @@ -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 = "[email protected]";
let expected = "https://acmecomputercompany-my.sharepoint.com/personal/lightmand_acmecomputercompany_com/_layouts/15/onedrive.aspx";

assert_eq!(expected, get_onedrive_url(email_address));
}
}
17 changes: 16 additions & 1 deletion core/src/smtp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions core/src/util/input_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 5d3c49f

Please sign in to comment.