diff --git a/docs/usage.md b/docs/usage.md index b6982c3..8c9c83c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -26,7 +26,26 @@ policy, TLS handling -- besides its own. The ones that matter most: | `-v`, `-vv`, `-vvv` | Increase log verbosity. | | `-q, --quiet` | Warnings and errors only. | | `--max-retries ` | Max retries per request on transient failures (default 5). | -| `--allow-invalid-certs` | Accept self-signed / invalid TLS certs. | +| `--allow-invalid-certs` | Accept a self-signed or otherwise invalid certificate from the server named by `--url` (see below). | + +### Invalid certificates + +`--allow-invalid-certs` is for a server with a self-signed certificate, and +it applies to that server only: the host in `--url`, whether that is the +source of an import or the target of an export. For an Exchange import with +no `--url`, it covers the mailbox's own domain and the hosts under it, which +is where on-premises Autodiscover looks, and then only the EWS endpoint +Autodiscover finds. + +Every other host is verified as usual, including a host the server +redirects to or names for its API, uploads or downloads. The Microsoft and +Google sign-in and cloud endpoints are always verified, with or without the +flag: a certificate that fails there is an attack or a broken network, never +a server to trust. + +Connections time out rather than wait forever: 30 seconds to connect, 5 +minutes for the server's first byte, and 30 minutes to read a whole +response. A timed-out request is retried like any other transient failure. Secrets come from the `INBUXA_MIGRATE_*` environment variables or a prompt; see [Credentials](../README.md#credentials). The command line takes them too, diff --git a/src/cli.rs b/src/cli.rs index 92d5370..f585785 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -122,7 +122,10 @@ struct GlobalArgs { )] max_retries: u32, - #[arg(long, help = "Accept self-signed / invalid TLS certificates")] + #[arg( + long, + help = "Accept an invalid TLS certificate from the --url host only; sign-in endpoints are always verified" + )] allow_invalid_certs: bool, } diff --git a/src/dav/client.rs b/src/dav/client.rs index c6ebe81..799601f 100644 --- a/src/dav/client.rs +++ b/src/dav/client.rs @@ -14,7 +14,6 @@ use ureq::Agent; use ureq::Body; use ureq::config::{Config, RedirectAuthHeaders}; use ureq::http::{Method, Request, Response}; -use ureq::tls::{RootCerts, TlsConfig}; use crate::dav::parse::{ControlStrippingReader, DavResponse, parse_multistatus}; use crate::dav::retry::{DavOutcome, classify}; @@ -22,6 +21,7 @@ use crate::jmap::error::JmapError; use crate::jmap::http::{Auth, RetryPolicy, retry_after_header}; use crate::jmap::retry::{self, RateLimitState}; use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; +use crate::net::{CertOverride, tls, with_timeouts}; const MAX_BODY: u64 = 512 * 1024 * 1024; const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10); @@ -48,6 +48,8 @@ pub struct MultiStatus { struct Inner { agent: Agent, + lax_agent: Option, + certs: CertOverride, auth: Auth, retry: RetryPolicy, rate_limit: RateLimitState, @@ -57,31 +59,42 @@ struct Inner { user_agent: String, } +impl Inner { + /// The agent for `url`: the one that accepts invalid certificates only for + /// a host `--allow-invalid-certs` covers, and the verifying one otherwise. + fn agent_for(&self, url: &str) -> &Agent { + match &self.lax_agent { + Some(lax) if self.certs.allows(url) => lax, + _ => &self.agent, + } + } +} + #[derive(Clone)] pub struct DavClient { inner: Arc, } impl DavClient { - pub fn new(auth: Auth, retry: RetryPolicy, allow_invalid_certs: bool) -> Self { - let config: Config = Config::builder() - .http_status_as_error(false) - .allow_non_standard_methods(true) - .max_redirects(0) - .redirect_auth_headers(RedirectAuthHeaders::SameHost) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), + pub fn new(auth: Auth, retry: RetryPolicy, certs: CertOverride) -> Self { + let build = |accept_invalid: bool| -> Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .allow_non_standard_methods(true) + .max_redirects(0) + .redirect_auth_headers(RedirectAuthHeaders::SameHost) + .tls_config(tls(accept_invalid)) ) .build(); + config.new_agent() + }; + let lax_agent = certs.is_active().then(|| build(true)); DavClient { inner: Arc::new(Inner { - agent: config.new_agent(), + agent: build(false), + lax_agent, + certs, auth, retry, rate_limit: RateLimitState::new(), @@ -822,7 +835,7 @@ impl DavClient { let request = builder .body(payload) .map_err(|e| ureq::Error::Other(Box::new(std::io::Error::other(e))))?; - self.inner.agent.run(request) + self.inner.agent_for(req.url).run(request) } } @@ -941,6 +954,25 @@ fn truncate(body: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::net::CertOverride; + + #[test] + fn every_timeout_is_a_retryable_transport_error() { + for t in [ + ureq::Timeout::Connect, + ureq::Timeout::SendRequest, + ureq::Timeout::SendBody, + ureq::Timeout::RecvResponse, + ureq::Timeout::RecvBody, + ] { + let err = map_ureq_error(ureq::Error::Timeout(t)); + assert!(matches!(err, JmapError::Transport(_)), "{t:?} -> {err:?}"); + assert!( + matches!(transport_disposition(&err), retry::Disposition::Retryable), + "{t:?} must be retried" + ); + } + } #[test] fn client_constructs_cleanly() { @@ -950,7 +982,7 @@ mod tests { password: "p".into(), }, RetryPolicy::new(3), - false, + CertOverride::none(), ); assert_eq!(c.retries_observed(), 0); assert_eq!(c.retry_after_sleeps(), 0); @@ -963,7 +995,7 @@ mod tests { token: "abc".into(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); let logger = c.logger(); assert_eq!(logger.level(), LEVEL_DEFAULT); diff --git a/src/exchange_ews/autodiscover.rs b/src/exchange_ews/autodiscover.rs index cb28b4f..cc878b7 100644 --- a/src/exchange_ews/autodiscover.rs +++ b/src/exchange_ews/autodiscover.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -9,10 +10,10 @@ use quick_xml::events::Event; use serde_json::Value; use ureq::Agent; use ureq::config::Config; -use ureq::tls::{RootCerts, TlsConfig}; use crate::exchange_ews::error::EwsError; use crate::exchange_ews::parse::entity_to_char; +use crate::net::{CertOverride, tls, with_timeouts}; const V2_HOST: &str = "https://outlook.office365.com"; const POX_REQ_NS: &str = @@ -48,7 +49,7 @@ pub fn discover( supplied_url: Option<&str>, email: Option<&str>, auth_header: Option<&str>, - allow_invalid_certs: bool, + certs: &CertOverride, ) -> Result { if let Some(url) = supplied_url && is_fully_qualified_ews_url(url) @@ -63,8 +64,17 @@ pub fn discover( "either a fully-qualified --url or --mailbox is required".to_owned(), )); }; - let agent = build_agent(allow_invalid_certs); - if let Ok(url) = autodiscover_v2(&agent, email) { + // Autodiscover v2 is Microsoft's own service and is always verified; a v1 + // candidate gets the relaxed agent only if `--allow-invalid-certs` covers it. + let strict = build_agent(false); + let lax = certs.is_active().then(|| build_agent(true)); + let pick = |url: &str| -> &Agent { + match &lax { + Some(agent) if certs.allows(url) => agent, + _ => &strict, + } + }; + if let Ok(url) = autodiscover_v2(&strict, email) { return Ok(DiscoveryResult { ews_url: url, source: DiscoverySource::V2, @@ -82,7 +92,7 @@ pub fn discover( let candidates = pox_candidates(domain); for candidate in &candidates { tried.push(candidate.clone()); - match autodiscover_v1(&agent, candidate, ¤t_email, auth_header) { + match autodiscover_v1(pick(candidate), candidate, ¤t_email, auth_header) { Ok(PoxOutcome::EwsUrl(url)) => { return Ok(DiscoveryResult { ews_url: url, @@ -104,7 +114,7 @@ pub fn discover( url_redirects += 1; tried.push(url.clone()); if let Ok(PoxOutcome::EwsUrl(u)) = - autodiscover_v1(&agent, &url, ¤t_email, auth_header) + autodiscover_v1(pick(&url), &url, ¤t_email, auth_header) { return Ok(DiscoveryResult { ews_url: u, @@ -130,19 +140,13 @@ pub fn discover( ))) } -fn build_agent(allow_invalid_certs: bool) -> Agent { - let config: Config = Config::builder() - .http_status_as_error(false) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), - ) - .build(); +fn build_agent(accept_invalid: bool) -> Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .tls_config(tls(accept_invalid)) + ) + .build(); config.new_agent() } diff --git a/src/exchange_ews/client.rs b/src/exchange_ews/client.rs index d7ab073..2fa82d9 100644 --- a/src/exchange_ews/client.rs +++ b/src/exchange_ews/client.rs @@ -12,7 +12,6 @@ use std::time::{Duration, Instant}; use ureq::Agent; use ureq::config::{Config, RedirectAuthHeaders}; -use ureq::tls::{RootCerts, TlsConfig}; use crate::exchange_ews::error::EwsError; use crate::exchange_ews::parse::{EnvelopeKind, SoapFault, read_envelope_summary}; @@ -22,12 +21,15 @@ use crate::exchange_ews::types::ServerVersion; use crate::jmap::http::{Auth, RetryPolicy, retry_after_header}; use crate::jmap::retry::{self, Disposition, RateLimitState}; use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; +use crate::net::{CertOverride, tls, with_timeouts}; const MAX_BODY: u64 = 2 * 1024 * 1024 * 1024; const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10); struct Inner { agent: Agent, + lax_agent: Option, + certs: CertOverride, auth: Mutex, impersonated_smtp: Mutex>, anchor_mailbox: Mutex>, @@ -42,6 +44,17 @@ struct Inner { user_agent: String, } +impl Inner { + /// The agent for `url`: the one that accepts invalid certificates only for + /// a host `--allow-invalid-certs` covers, and the verifying one otherwise. + fn agent_for(&self, url: &str) -> &Agent { + match &self.lax_agent { + Some(lax) if self.certs.allows(url) => lax, + _ => &self.agent, + } + } +} + #[derive(Clone)] pub struct EwsClient { inner: Arc, @@ -54,23 +67,23 @@ pub struct SoapResponse { } impl EwsClient { - pub fn new(auth: Auth, retry: RetryPolicy, allow_invalid_certs: bool) -> EwsClient { - let config: Config = Config::builder() - .http_status_as_error(false) - .redirect_auth_headers(RedirectAuthHeaders::SameHost) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), + pub fn new(auth: Auth, retry: RetryPolicy, certs: CertOverride) -> EwsClient { + let build = |accept_invalid: bool| -> Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .redirect_auth_headers(RedirectAuthHeaders::SameHost) + .tls_config(tls(accept_invalid)) ) .build(); + config.new_agent() + }; + let lax_agent = certs.is_active().then(|| build(true)); EwsClient { inner: Arc::new(Inner { - agent: config.new_agent(), + agent: build(false), + lax_agent, + certs, auth: Mutex::new(auth), impersonated_smtp: Mutex::new(None), anchor_mailbox: Mutex::new(None), @@ -408,7 +421,7 @@ impl EwsClient { fn one_attempt(&self, url: &str, body: &str, action: &str) -> AttemptOutcome { let mut req = self .inner - .agent + .agent_for(url) .post(url) .header("Authorization", self.auth_header()) .header("Content-Type", "text/xml; charset=utf-8") @@ -535,13 +548,29 @@ fn truncate(body: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::net::CertOverride; + + #[test] + fn every_timeout_is_a_transport_error_and_so_retried() { + // Every EwsError::Transport goes round the retry loop in `execute`. + for t in [ + ureq::Timeout::Connect, + ureq::Timeout::SendRequest, + ureq::Timeout::SendBody, + ureq::Timeout::RecvResponse, + ureq::Timeout::RecvBody, + ] { + let err = map_ureq_error(ureq::Error::Timeout(t)); + assert!(matches!(err, EwsError::Transport(_)), "{t:?} -> {err:?}"); + } + } #[test] fn client_constructs_with_defaults() { let c = EwsClient::new( Auth::Bearer { token: "t".into() }, RetryPolicy::new(3), - false, + CertOverride::none(), ); assert_eq!(c.server_version(), ServerVersion::Exchange2013Sp1); assert_eq!(c.retries_observed(), 0); @@ -553,7 +582,7 @@ mod tests { let c = EwsClient::new( Auth::Bearer { token: "t".into() }, RetryPolicy::new(0), - false, + CertOverride::none(), ); c.set_server_version(ServerVersion::Exchange2019); assert_eq!(c.server_version(), ServerVersion::Exchange2019); @@ -564,7 +593,7 @@ mod tests { let c = EwsClient::new( Auth::Bearer { token: "t".into() }, RetryPolicy::new(0), - false, + CertOverride::none(), ); c.set_anchor_mailbox(Some("alice@x".to_owned())); assert_eq!(c.anchor_header().as_deref(), Some("alice@x")); diff --git a/src/exchange_ews/oauth.rs b/src/exchange_ews/oauth.rs index e00e935..e5d391c 100644 --- a/src/exchange_ews/oauth.rs +++ b/src/exchange_ews/oauth.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -10,9 +11,9 @@ use std::time::Duration; use encodify::base64::{Base64, Padding, URL_SAFE}; use serde_json::Value; use ureq::config::Config; -use ureq::tls::{RootCerts, TlsConfig}; use crate::exchange_ews::error::EwsError; +use crate::net::{tls, with_timeouts}; pub const SCOPE_APP_ONLY: &str = "https://outlook.office365.com/.default"; pub const SCOPE_DELEGATED: &str = @@ -46,7 +47,7 @@ pub enum OAuthFlow { }, } -pub fn acquire(flow: &OAuthFlow, allow_invalid_certs: bool) -> Result { +pub fn acquire(flow: &OAuthFlow) -> Result { match flow { OAuthFlow::PreAcquired { token } => { let claims = decode_jwt_claims(token).unwrap_or_default(); @@ -63,10 +64,8 @@ pub fn acquire(flow: &OAuthFlow, allow_invalid_certs: bool) -> Result client_credentials(tenant, client_id, client_secret, allow_invalid_certs), - OAuthFlow::DeviceCode { tenant, client_id } => { - device_code_flow(tenant, client_id, allow_invalid_certs) - } + } => client_credentials(tenant, client_id, client_secret), + OAuthFlow::DeviceCode { tenant, client_id } => device_code_flow(tenant, client_id), } } @@ -104,19 +103,13 @@ fn device_code_endpoint(tenant: &str) -> String { format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode") } -fn build_agent(allow_invalid_certs: bool) -> ureq::Agent { - let config: Config = Config::builder() - .http_status_as_error(false) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), - ) - .build(); +fn build_agent() -> ureq::Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .tls_config(tls(false)) + ) + .build(); config.new_agent() } @@ -124,9 +117,8 @@ fn client_credentials( tenant: &str, client_id: &str, client_secret: &str, - allow_invalid_certs: bool, ) -> Result { - let agent = build_agent(allow_invalid_certs); + let agent = build_agent(); let body = form_encode(&[ ("client_id", client_id), ("client_secret", client_secret), @@ -142,12 +134,8 @@ fn client_credentials( parse_token_response(resp) } -fn device_code_flow( - tenant: &str, - client_id: &str, - allow_invalid_certs: bool, -) -> Result { - let agent = build_agent(allow_invalid_certs); +fn device_code_flow(tenant: &str, client_id: &str) -> Result { + let agent = build_agent(); let body = form_encode(&[("client_id", client_id), ("scope", SCOPE_DELEGATED)]); let endpoint = device_code_endpoint(tenant); let mut resp = agent @@ -279,9 +267,8 @@ pub fn refresh_with_token( tenant: &str, client_id: &str, refresh_token: &str, - allow_invalid_certs: bool, ) -> Result { - let agent = build_agent(allow_invalid_certs); + let agent = build_agent(); let body = form_encode(&[ ("client_id", client_id), ("grant_type", "refresh_token"), @@ -366,12 +353,9 @@ mod tests { #[test] fn pre_acquired_flow_decodes_claims() { let token = make_jwt("t-2", "bob@x", 9999999999); - let acq = acquire( - &OAuthFlow::PreAcquired { - token: token.clone(), - }, - false, - ) + let acq = acquire(&OAuthFlow::PreAcquired { + token: token.clone(), + }) .unwrap(); assert_eq!(acq.access_token, token); assert_eq!(acq.tenant_id.as_deref(), Some("t-2")); diff --git a/src/exchange_graph/client.rs b/src/exchange_graph/client.rs index 40945ec..886c43a 100644 --- a/src/exchange_graph/client.rs +++ b/src/exchange_graph/client.rs @@ -13,7 +13,6 @@ use std::time::{Duration, Instant}; use serde_json::Value; use ureq::Agent; use ureq::config::{Config, RedirectAuthHeaders}; -use ureq::tls::{RootCerts, TlsConfig}; use ureq::{ResponseExt, http::Uri}; use crate::exchange_graph::error::GraphError; @@ -21,6 +20,7 @@ use crate::exchange_graph::retry::{HttpClass, classify_http_status, is_throttled use crate::jmap::http::{RetryPolicy, cross_host, retry_after_header}; use crate::jmap::retry::{self, RateLimitState}; use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; +use crate::net::{CertOverride, tls, with_timeouts}; const MAX_BODY: u64 = 256 * 1024 * 1024; const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10); @@ -63,6 +63,8 @@ impl GraphResponse { struct Inner { agent: Agent, + lax_agent: Option, + certs: CertOverride, bearer: Mutex, retry: RetryPolicy, rate_limit: RateLimitState, @@ -73,6 +75,17 @@ struct Inner { user_agent: String, } +impl Inner { + /// The agent for `url`: the one that accepts invalid certificates only for + /// a host `--allow-invalid-certs` covers, and the verifying one otherwise. + fn agent_for(&self, url: &str) -> &Agent { + match &self.lax_agent { + Some(lax) if self.certs.allows(url) => lax, + _ => &self.agent, + } + } +} + #[derive(Clone)] pub struct GraphClient { inner: Arc, @@ -89,23 +102,23 @@ enum Attempt { } impl GraphClient { - pub fn new(bearer: String, retry: RetryPolicy, allow_invalid_certs: bool) -> GraphClient { - let config: Config = Config::builder() - .http_status_as_error(false) - .redirect_auth_headers(RedirectAuthHeaders::SameHost) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), + pub fn new(bearer: String, retry: RetryPolicy, certs: CertOverride) -> GraphClient { + let build = |accept_invalid: bool| -> Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .redirect_auth_headers(RedirectAuthHeaders::SameHost) + .tls_config(tls(accept_invalid)) ) .build(); + config.new_agent() + }; + let lax_agent = certs.is_active().then(|| build(true)); GraphClient { inner: Arc::new(Inner { - agent: config.new_agent(), + agent: build(false), + lax_agent, + certs, bearer: Mutex::new(bearer), retry, rate_limit: RateLimitState::new(), @@ -310,7 +323,7 @@ impl GraphClient { extra_prefer: &[&str], ) -> Attempt { let mut req = match method { - "GET" => self.inner.agent.get(url), + "GET" => self.inner.agent_for(url).get(url), other => { return Attempt::Transport(GraphError::Connect(format!( "unsupported method {other} (graph importer is read-only)" @@ -474,10 +487,30 @@ fn format_retry_wait(d: Duration) -> String { #[cfg(test)] mod tests { use super::*; + use crate::net::CertOverride; + + #[test] + fn every_timeout_is_a_transport_error_and_so_retried() { + // `execute` retries every GraphError::Transport; only Connect is fatal. + for t in [ + ureq::Timeout::Connect, + ureq::Timeout::SendRequest, + ureq::Timeout::SendBody, + ureq::Timeout::RecvResponse, + ureq::Timeout::RecvBody, + ] { + let err = map_ureq_error(ureq::Error::Timeout(t)); + assert!(matches!(err, GraphError::Transport(_)), "{t:?} -> {err:?}"); + } + } #[test] fn defaults_construct() { - let c = GraphClient::new("token".to_owned(), RetryPolicy::new(3), false); + let c = GraphClient::new( + "token".to_owned(), + RetryPolicy::new(3), + CertOverride::none(), + ); assert_eq!(c.retries_observed(), 0); assert_eq!(c.retry_after_sleeps(), 0); assert_eq!(c.requests_observed(), 0); @@ -486,7 +519,7 @@ mod tests { #[test] fn bearer_can_be_swapped_at_runtime() { - let c = GraphClient::new("old".to_owned(), RetryPolicy::new(0), false); + let c = GraphClient::new("old".to_owned(), RetryPolicy::new(0), CertOverride::none()); c.set_bearer("new".to_owned()); assert_eq!(c.auth_header(), "Bearer new"); } diff --git a/src/exchange_graph/oauth.rs b/src/exchange_graph/oauth.rs index b240111..2e264d0 100644 --- a/src/exchange_graph/oauth.rs +++ b/src/exchange_graph/oauth.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -10,9 +11,9 @@ use std::time::{Duration, Instant}; use encodify::base64::{Base64, Padding, URL_SAFE}; use serde_json::Value; use ureq::config::Config; -use ureq::tls::{RootCerts, TlsConfig}; use crate::exchange_graph::error::GraphError; +use crate::net::{tls, with_timeouts}; pub const SCOPES: &str = "offline_access User.Read Mail.Read MailboxSettings.Read Calendars.Read Contacts.Read"; @@ -78,29 +79,23 @@ pub struct AcquiredToken { pub name: Option, } -fn build_agent(allow_invalid_certs: bool) -> ureq::Agent { - let config: Config = Config::builder() - .http_status_as_error(false) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), - ) - .build(); +fn build_agent() -> ureq::Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .tls_config(tls(false)) + ) + .build(); config.new_agent() } -pub fn acquire(flow: &OAuthFlow, allow_invalid_certs: bool) -> Result { +pub fn acquire(flow: &OAuthFlow) -> Result { match flow { OAuthFlow::PreAcquired { token } => Ok(token_from_string(token.clone())), OAuthFlow::DeviceCode { authority, client_id, - } => device_code_flow(authority, client_id, allow_invalid_certs), + } => device_code_flow(authority, client_id), } } @@ -213,12 +208,8 @@ pub fn parse_token_response(status: u16, json: &Value) -> TokenResponse { } } -fn device_code_flow( - authority: &str, - client_id: &str, - allow_invalid_certs: bool, -) -> Result { - let agent = build_agent(allow_invalid_certs); +fn device_code_flow(authority: &str, client_id: &str) -> Result { + let agent = build_agent(); let body = form_encode(&[("client_id", client_id), ("scope", SCOPES)]); let endpoint = device_code_endpoint(authority); let mut resp = agent @@ -294,9 +285,8 @@ pub fn refresh_access_token( authority: &str, client_id: &str, refresh_token: &str, - allow_invalid_certs: bool, ) -> Result { - let agent = build_agent(allow_invalid_certs); + let agent = build_agent(); let body = form_encode(&[ ("client_id", client_id), ("grant_type", "refresh_token"), diff --git a/src/jmap/account.rs b/src/jmap/account.rs index e2ff271..a4ad3a6 100644 --- a/src/jmap/account.rs +++ b/src/jmap/account.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -170,6 +171,7 @@ fn extract_account_id(principal: &Value, name: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::net::CertOverride; fn session_with(name: &str, id: &str) -> Session { let raw = serde_json::json!({ @@ -186,7 +188,7 @@ mod tests { HttpClient::new( crate::jmap::http::Auth::Bearer { token: "t".into() }, crate::jmap::http::RetryPolicy::new(0), - false, + CertOverride::none(), ) } diff --git a/src/jmap/http.rs b/src/jmap/http.rs index 77c8ed8..4623c46 100644 --- a/src/jmap/http.rs +++ b/src/jmap/http.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -13,7 +14,6 @@ use encodify::base64::STANDARD; use serde_json::Value; use ureq::Agent; use ureq::config::{Config, RedirectAuthHeaders}; -use ureq::tls::{RootCerts, TlsConfig}; use ureq::{ResponseExt, http::Uri}; use crate::jmap::error::JmapError; @@ -21,6 +21,7 @@ use crate::jmap::inflight::{Permit, Semaphore}; use crate::jmap::retry::{self, Disposition, RateLimitState}; use crate::jmap::session::Limits; use crate::logging::{HttpCall, LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; +use crate::net::{CertOverride, send_body_budget, tls, with_timeouts}; const MAX_BODY: u64 = 512 * 1024 * 1024; @@ -69,9 +70,10 @@ impl RetryPolicy { struct Inner { agent: Agent, + lax_agent: Option, + certs: CertOverride, auth: Auth, retry: RetryPolicy, - allow_invalid_certs: bool, rate_limit: RateLimitState, log_level: AtomicU8, requests_gate: OnceLock, @@ -81,6 +83,17 @@ struct Inner { retry_after_sleeps: AtomicU64, } +impl Inner { + /// The agent for `url`: the one that accepts invalid certificates only for + /// a host `--allow-invalid-certs` covers, and the verifying one otherwise. + fn agent_for(&self, url: &str) -> &Agent { + match &self.lax_agent { + Some(lax) if self.certs.allows(url) => lax, + _ => &self.agent, + } + } +} + #[derive(Debug, Clone, Copy)] enum Kind { Api, @@ -103,26 +116,25 @@ enum Attempt { } impl HttpClient { - pub fn new(auth: Auth, retry: RetryPolicy, allow_invalid_certs: bool) -> Self { - let config: Config = Config::builder() - .http_status_as_error(false) - .redirect_auth_headers(RedirectAuthHeaders::SameHost) - .tls_config( - TlsConfig::builder() - .unversioned_rustls_crypto_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .root_certs(RootCerts::PlatformVerifier) - .disable_verification(allow_invalid_certs) - .build(), + pub fn new(auth: Auth, retry: RetryPolicy, certs: CertOverride) -> Self { + let build = |accept_invalid: bool| -> Agent { + let config: Config = with_timeouts!( + Config::builder() + .http_status_as_error(false) + .redirect_auth_headers(RedirectAuthHeaders::SameHost) + .tls_config(tls(accept_invalid)) ) .build(); + config.new_agent() + }; + let lax_agent = certs.is_active().then(|| build(true)); HttpClient { inner: Arc::new(Inner { - agent: config.new_agent(), + agent: build(false), + lax_agent, + certs, auth, retry, - allow_invalid_certs, rate_limit: RateLimitState::new(), log_level: AtomicU8::new(LEVEL_DEFAULT), requests_gate: OnceLock::new(), @@ -172,10 +184,6 @@ impl HttpClient { &self.inner.retry } - pub fn allow_invalid_certs(&self) -> bool { - self.inner.allow_invalid_certs - } - pub fn rate_limit(&self) -> &RateLimitState { &self.inner.rate_limit } @@ -386,17 +394,22 @@ impl HttpClient { let result = if let Some(payload) = body { let mut req = self .inner - .agent + .agent_for(url) .post(url) .header("Authorization", auth) .header("Accept", "application/json"); if let Some(ct) = content_type { req = req.header("Content-Type", ct); } - req.send(payload) + // A blob upload can run to hundreds of megabytes, so its send + // budget grows with its size instead of the agent's flat default. + req.config() + .timeout_send_body(Some(send_body_budget(payload.len()))) + .build() + .send(payload) } else { self.inner - .agent + .agent_for(url) .get(url) .header("Authorization", auth) .header("Accept", "application/json") @@ -644,6 +657,66 @@ pub fn format_retry_wait(d: Duration) -> String { #[cfg(test)] mod tests { use super::*; + use crate::net::CertOverride; + + #[test] + fn invalid_certificates_are_accepted_only_for_the_named_host() { + let client = HttpClient::new( + Auth::Bearer { + token: "t".to_owned(), + }, + RetryPolicy::new(0), + CertOverride::for_url(true, "https://mail.example.test/.well-known/jmap"), + ); + let inner = &client.inner; + let lax = inner.lax_agent.as_ref().expect("a relaxed agent exists"); + assert!(std::ptr::eq( + inner.agent_for("https://mail.example.test/api"), + lax + )); + assert!(std::ptr::eq( + inner.agent_for("https://files.example.test/upload"), + &inner.agent + )); + assert!(std::ptr::eq( + inner.agent_for("https://login.microsoftonline.com/common/oauth2/v2.0/token"), + &inner.agent + )); + } + + #[test] + fn without_the_flag_there_is_no_relaxed_agent() { + let client = HttpClient::new( + Auth::Bearer { + token: "t".to_owned(), + }, + RetryPolicy::new(0), + CertOverride::for_url(false, "https://mail.example.test/"), + ); + assert!(client.inner.lax_agent.is_none()); + assert!(std::ptr::eq( + client.inner.agent_for("https://mail.example.test/api"), + &client.inner.agent + )); + } + + #[test] + fn every_timeout_is_a_retryable_transport_error() { + for t in [ + ureq::Timeout::Connect, + ureq::Timeout::SendRequest, + ureq::Timeout::SendBody, + ureq::Timeout::RecvResponse, + ureq::Timeout::RecvBody, + ] { + let err = map_ureq_error(ureq::Error::Timeout(t)); + assert!(matches!(err, JmapError::Transport(_)), "{t:?} -> {err:?}"); + assert!( + matches!(transport_disposition(&err), Disposition::Retryable), + "{t:?} must be retried" + ); + } + } #[test] fn basic_header_matches_rfc7617_example() { @@ -711,7 +784,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); let body = br#"{"type":"urn:ietf:params:jmap:error:limit","limit":"someServerLimit"}"#; assert!(matches!( @@ -727,7 +800,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); let body = br#"{"type":"urn:ietf:params:jmap:error:limit","limit":"maxSizeRequest"}"#; assert!(matches!( @@ -743,7 +816,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); let body = br#"{"type":"urn:ietf:params:jmap:error:limit","limit":"maxConcurrentRequests"}"#; @@ -772,7 +845,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); client.set_limits(&limits_with(10, 4, 4)); let err = client @@ -796,7 +869,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); client.set_limits(&limits_with(1024, 4, 4)); let err = client @@ -848,7 +921,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(0), - false, + CertOverride::none(), ); assert_eq!(client.retries_observed(), 0); assert_eq!(client.retry_after_sleeps(), 0); diff --git a/src/jmap/request.rs b/src/jmap/request.rs index c576530..7a6b4cc 100644 --- a/src/jmap/request.rs +++ b/src/jmap/request.rs @@ -786,6 +786,7 @@ fn decode_set(mr: &MethodCall) -> SetOutcome { #[cfg(test)] mod tests { + use crate::net::CertOverride; use std::cell::Cell; use super::*; @@ -798,7 +799,7 @@ mod tests { token: "t".to_owned(), }, RetryPolicy::new(max_retries), - false, + CertOverride::none(), ) } diff --git a/src/lib.rs b/src/lib.rs index d20a878..6c40edf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -16,6 +17,7 @@ pub mod inspect; pub mod jmap; pub mod logging; pub mod managesieve; +pub mod net; pub mod secret; pub mod sync; pub mod types; diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..40c87ad --- /dev/null +++ b/src/net.rs @@ -0,0 +1,284 @@ +/* + * SPDX-FileCopyrightText: 2026 John Coffey + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +//! Settings every HTTP agent shares: timeouts, TLS, and which hosts, if any, +//! may present a certificate that does not verify. + +use std::time::Duration; + +use ureq::tls::{RootCerts, TlsConfig}; + +/// Opening the socket and completing any TLS handshake. +pub const CONNECT: Duration = Duration::from_secs(30); + +/// Writing the request line and headers. +pub const SEND_REQUEST: Duration = Duration::from_secs(60); + +/// Waiting for the response headers once the request is sent. This is the +/// server's thinking time: a large `Email/import`, an EWS `FindItem` over a big +/// folder or a CalDAV REPORT can legitimately take a while before the first +/// byte comes back. +pub const RECV_RESPONSE: Duration = Duration::from_secs(5 * 60); + +/// Reading the whole response body. ureq counts this as one budget for the +/// entire body, not per read, so it has to cover the largest body a client +/// accepts (512 MiB) on a slow link: 30 minutes is about 300 KB/s. A stalled +/// transfer is abandoned and retried after at most this long. +pub const RECV_BODY: Duration = Duration::from_secs(30 * 60); + +/// Sending a request body when its size is not known in advance. Uploads know +/// their size and get [`send_body_budget`] instead. +pub const SEND_BODY: Duration = Duration::from_secs(30 * 60); + +/// The slowest upload rate a send budget allows for, in bytes per second. +const MIN_UPLOAD_RATE: u64 = 64 * 1024; + +/// The floor under every send budget, so small bodies still get a sensible +/// allowance on a slow or busy connection. +const SEND_BODY_FLOOR: Duration = Duration::from_secs(2 * 60); + +/// How long sending a body of `len` bytes may take: the floor plus the time it +/// takes at [`MIN_UPLOAD_RATE`]. +pub fn send_body_budget(len: usize) -> Duration { + SEND_BODY_FLOOR + Duration::from_secs(len as u64 / MIN_UPLOAD_RATE) +} + +/// Applies the shared timeouts to a ureq `ConfigBuilder`. A macro rather than +/// a function because ureq keeps the builder's scope types private, so a +/// function could not name them. +macro_rules! with_timeouts { + ($builder:expr) => { + $builder + .timeout_connect(Some($crate::net::CONNECT)) + .timeout_send_request(Some($crate::net::SEND_REQUEST)) + .timeout_send_body(Some($crate::net::SEND_BODY)) + .timeout_recv_response(Some($crate::net::RECV_RESPONSE)) + .timeout_recv_body(Some($crate::net::RECV_BODY)) + }; +} +pub(crate) use with_timeouts; + +/// TLS settings for an agent: the platform's roots, and certificate checks off +/// only when `accept_invalid` is set. +pub fn tls(accept_invalid: bool) -> TlsConfig { + TlsConfig::builder() + .unversioned_rustls_crypto_provider(std::sync::Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .root_certs(RootCerts::PlatformVerifier) + .disable_verification(accept_invalid) + .build() +} + +/// Hosts that are always verified, whatever `--allow-invalid-certs` says: +/// the Microsoft and Google sign-in and cloud endpoints. A certificate that +/// fails there is an attack or a broken network, never a self-signed server +/// the user meant to trust. Matched as a suffix on a label boundary. +const ALWAYS_VERIFY: &[&str] = &[ + "microsoftonline.com", + "microsoftonline.us", + "microsoft.com", + "microsoft.us", + "office365.com", + "office.com", + "outlook.com", + "chinacloudapi.cn", + "partner.outlook.cn", + "google.com", + "googleapis.com", + "gmail.com", +]; + +/// Where `--allow-invalid-certs` applies: the host the user named, or, for +/// Exchange Autodiscover without a `--url`, the mailbox's own domain and its +/// subdomains. Everything else, including any host a server redirects or +/// points to, is verified as usual. +#[derive(Debug, Clone, Default)] +pub struct CertOverride { + hosts: Vec, + domains: Vec, +} + +impl CertOverride { + /// Verify everything. + pub fn none() -> Self { + Self::default() + } + + /// When `enabled`, accept invalid certificates from the host of `url`. + pub fn for_url(enabled: bool, url: &str) -> Self { + match (enabled, host_of(url)) { + (true, Some(host)) if !always_verified(&host) => CertOverride { + hosts: vec![host], + domains: Vec::new(), + }, + _ => Self::none(), + } + } + + /// When `enabled`, accept invalid certificates from `domain` and every + /// host under it. + pub fn for_domain(enabled: bool, domain: &str) -> Self { + let domain = domain.trim_end_matches('.').to_ascii_lowercase(); + if enabled && !domain.is_empty() && !always_verified(&domain) { + CertOverride { + hosts: Vec::new(), + domains: vec![domain], + } + } else { + Self::none() + } + } + + /// The same override, narrowed to the host of `url`, if `url` is one this + /// override already covers. Used once Autodiscover has found the real + /// endpoint. + pub fn narrowed_to(&self, url: &str) -> Self { + match host_of(url) { + Some(host) if self.allows_host(&host) => CertOverride { + hosts: vec![host], + domains: Vec::new(), + }, + _ => Self::none(), + } + } + + /// Whether this override covers anything at all. + pub fn is_active(&self) -> bool { + !self.hosts.is_empty() || !self.domains.is_empty() + } + + /// Whether a certificate that does not verify is accepted for `url`. + pub fn allows(&self, url: &str) -> bool { + host_of(url).is_some_and(|host| self.allows_host(&host)) + } + + fn allows_host(&self, host: &str) -> bool { + if always_verified(host) { + return false; + } + self.hosts.iter().any(|h| h == host) + || self + .domains + .iter() + .any(|d| host == d || host.ends_with(&format!(".{d}"))) + } +} + +fn host_of(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + let host = parsed + .host_str()? + .trim_end_matches('.') + .to_ascii_lowercase(); + Some( + host.trim_start_matches('[') + .trim_end_matches(']') + .to_owned(), + ) +} + +fn always_verified(host: &str) -> bool { + ALWAYS_VERIFY + .iter() + .any(|d| host == *d || host.ends_with(&format!(".{d}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_flag_covers_nothing() { + let o = CertOverride::for_url(false, "https://mail.example.test/jmap"); + assert!(!o.is_active()); + assert!(!o.allows("https://mail.example.test/jmap")); + } + + #[test] + fn covers_only_the_named_host() { + let o = CertOverride::for_url(true, "https://Mail.Example.test:8443/.well-known/jmap"); + assert!(o.is_active()); + assert!(o.allows("https://mail.example.test/api")); + assert!(o.allows("https://MAIL.example.test:9000/upload")); + assert!(!o.allows("https://files.example.test/download")); + assert!(!o.allows("https://example.test/")); + assert!(!o.allows("https://mail.example.test.evil.test/")); + } + + #[test] + fn sign_in_and_cloud_hosts_are_always_verified() { + for url in [ + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "https://graph.microsoft.com/v1.0/me", + "https://outlook.office365.com/EWS/Exchange.asmx", + "https://autodiscover-s.outlook.com/autodiscover/autodiscover.xml", + "https://oauth2.googleapis.com/token", + "https://accounts.google.com/o/oauth2/device/code", + ] { + let o = CertOverride::for_url(true, url); + assert!(!o.is_active(), "{url}"); + assert!(!o.allows(url), "{url}"); + } + } + + #[test] + fn a_domain_covers_its_subdomains_but_not_look_alikes() { + let o = CertOverride::for_domain(true, "Corp.Example."); + assert!(o.allows("https://autodiscover.corp.example/autodiscover/autodiscover.xml")); + assert!(o.allows("https://corp.example/autodiscover/autodiscover.xml")); + assert!(!o.allows("https://notcorp.example/")); + assert!(!o.allows("https://corp.example.evil.test/")); + } + + #[test] + fn a_domain_override_never_reaches_microsoft() { + let o = CertOverride::for_domain(true, "office365.com"); + assert!(!o.is_active()); + let corp = CertOverride::for_domain(true, "corp.example"); + assert!(!corp.allows("https://outlook.office365.com/EWS/Exchange.asmx")); + } + + #[test] + fn narrowing_keeps_only_a_covered_endpoint() { + let o = CertOverride::for_domain(true, "corp.example"); + let inside = o.narrowed_to("https://mail.corp.example/EWS/Exchange.asmx"); + assert!(inside.allows("https://mail.corp.example/EWS/Exchange.asmx")); + assert!(!inside.allows("https://autodiscover.corp.example/")); + let outside = o.narrowed_to("https://outlook.office365.com/EWS/Exchange.asmx"); + assert!(!outside.is_active()); + } + + #[test] + fn ip_literals_are_matched() { + let o = CertOverride::for_url(true, "https://[::1]:8443/jmap"); + assert!(o.allows("https://[::1]:9000/other")); + let v4 = CertOverride::for_url(true, "https://192.0.2.10/jmap"); + assert!(v4.allows("https://192.0.2.10:8443/")); + assert!(!v4.allows("https://192.0.2.11/")); + } + + #[test] + fn send_budget_grows_with_size() { + assert_eq!(send_body_budget(0), Duration::from_secs(120)); + assert_eq!( + send_body_budget(64 * 1024 * 600), + Duration::from_secs(120 + 600) + ); + assert!(send_body_budget(512 * 1024 * 1024) > Duration::from_secs(2 * 60 * 60)); + } + + #[test] + fn timeouts_are_applied_to_a_config() { + let config: ureq::config::Config = with_timeouts!(ureq::config::Config::builder()).build(); + let t = config.timeouts(); + assert_eq!(t.connect, Some(CONNECT)); + assert_eq!(t.send_request, Some(SEND_REQUEST)); + assert_eq!(t.send_body, Some(SEND_BODY)); + assert_eq!(t.recv_response, Some(RECV_RESPONSE)); + assert_eq!(t.recv_body, Some(RECV_BODY)); + } +} diff --git a/src/sync/import_dav/coordinator.rs b/src/sync/import_dav/coordinator.rs index 81db85a..b51bd2f 100644 --- a/src/sync/import_dav/coordinator.rs +++ b/src/sync/import_dav/coordinator.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -18,6 +19,7 @@ use crate::sync::{CommonConfig, RunOutcome, Summary, TypeCounts}; use super::collections; use super::items; use super::tree; +use crate::net::CertOverride; #[derive(Debug, Clone, Copy)] pub enum DavKindArg { @@ -105,7 +107,7 @@ fn run_into( let client = DavClient::new( config.auth.to_jmap_auth(), RetryPolicy::new(common.max_retries), - common.allow_invalid_certs, + CertOverride::for_url(common.allow_invalid_certs, &config.url), ); client.set_logger(logger); diff --git a/src/sync/import_exchange_ews/coordinator.rs b/src/sync/import_exchange_ews/coordinator.rs index dbdc538..053e77f 100644 --- a/src/sync/import_exchange_ews/coordinator.rs +++ b/src/sync/import_exchange_ews/coordinator.rs @@ -20,6 +20,7 @@ use crate::sync::{CommonConfig, Summary, TypeCounts}; use super::folders::{self, plan_folders}; use super::{calendar, contacts, messages}; +use crate::net::CertOverride; #[derive(Debug, Clone)] pub enum EwsAuth { @@ -45,8 +46,8 @@ pub fn run(common: CommonConfig, config: EwsImportConfig) -> Result Result Result user.clone(), @@ -211,10 +206,7 @@ fn run_dry( Ok(summary) } -fn resolve_auth( - auth: &EwsAuth, - allow_invalid_certs: bool, -) -> Result<(Auth, Option), Error> { +fn resolve_auth(auth: &EwsAuth) -> Result<(Auth, Option), Error> { match auth { EwsAuth::Basic { user, password } => Ok(( Auth::Basic { @@ -224,12 +216,9 @@ fn resolve_auth( None, )), EwsAuth::Bearer { token } => { - let acq = acquire( - &OAuthFlow::PreAcquired { - token: token.clone(), - }, - allow_invalid_certs, - ) + let acq = acquire(&OAuthFlow::PreAcquired { + token: token.clone(), + }) .map_err(Error::from)?; Ok(( Auth::Bearer { @@ -239,7 +228,7 @@ fn resolve_auth( )) } EwsAuth::OAuth(flow) => { - let acq = acquire(flow, allow_invalid_certs).map_err(Error::from)?; + let acq = acquire(flow).map_err(Error::from)?; Ok(( Auth::Bearer { token: acq.access_token.clone(), @@ -254,19 +243,36 @@ fn run_autodiscover( config: &EwsImportConfig, acquired: &Option, allow_invalid_certs: bool, -) -> Result { +) -> Result<(DiscoveryResult, CertOverride), Error> { let email = config .mailbox .clone() .or_else(|| acquired.as_ref().and_then(|a| a.upn.clone())); - let result = discover( - config.url.as_deref(), - email.as_deref(), - None, - allow_invalid_certs, - ) - .map_err(Error::from)?; - Ok(result) + let certs = + autodiscover_cert_override(config.url.as_deref(), email.as_deref(), allow_invalid_certs); + let result = + discover(config.url.as_deref(), email.as_deref(), None, &certs).map_err(Error::from)?; + Ok((result, certs)) +} + +/// Where `--allow-invalid-certs` applies for an EWS import: the host of +/// `--url` when one is given, and otherwise the mailbox's own domain, which is +/// where on-premises Autodiscover looks. Microsoft's hosts are never covered. +fn autodiscover_cert_override( + url: Option<&str>, + email: Option<&str>, + enabled: bool, +) -> CertOverride { + match ( + url, + email + .and_then(|e| e.rsplit_once('@')) + .map(|(_, domain)| domain), + ) { + (Some(url), _) => CertOverride::for_url(enabled, url), + (None, Some(domain)) => CertOverride::for_domain(enabled, domain), + (None, None) => CertOverride::none(), + } } fn resolve_mailbox( @@ -370,7 +376,6 @@ fn spawn_token_refresher( client: &EwsClient, auth: &EwsAuth, initial: &Option, - allow_invalid_certs: bool, logger: crate::logging::Logger, ) { let flow = match auth { @@ -402,14 +407,9 @@ fn spawn_token_refresher( let result = if let (Some(rt), OAuthFlow::DeviceCode { tenant, client_id }) = (refresh_token.as_deref(), &flow) { - crate::exchange_ews::oauth::refresh_with_token( - tenant, - client_id, - rt, - allow_invalid_certs, - ) + crate::exchange_ews::oauth::refresh_with_token(tenant, client_id, rt) } else { - crate::exchange_ews::oauth::acquire(&flow, allow_invalid_certs) + crate::exchange_ews::oauth::acquire(&flow) }; match result { Ok(tok) => { @@ -451,6 +451,26 @@ fn run_gc(conn: &Connection) -> Result<(), Error> { mod tests { use super::*; + #[test] + fn cert_override_follows_url_then_mailbox_domain() { + let by_url = autodiscover_cert_override( + Some("https://mail.corp.example/EWS/Exchange.asmx"), + Some("alice@corp.example"), + true, + ); + assert!(by_url.allows("https://mail.corp.example/EWS/Exchange.asmx")); + assert!(!by_url.allows("https://autodiscover.corp.example/")); + + let by_domain = autodiscover_cert_override(None, Some("alice@Corp.Example"), true); + assert!( + by_domain.allows("https://autodiscover.corp.example/autodiscover/autodiscover.xml") + ); + assert!(!by_domain.allows("https://outlook.office365.com/EWS/Exchange.asmx")); + + assert!(!autodiscover_cert_override(None, Some("alice@corp.example"), false).is_active()); + assert!(!autodiscover_cert_override(None, None, true).is_active()); + } + #[test] fn synthetic_account_id_uses_smtp_for_primary() { assert_eq!( diff --git a/src/sync/import_exchange_graph/coordinator.rs b/src/sync/import_exchange_graph/coordinator.rs index 922141e..7d0e706 100644 --- a/src/sync/import_exchange_graph/coordinator.rs +++ b/src/sync/import_exchange_graph/coordinator.rs @@ -20,6 +20,7 @@ use crate::exchange_graph::oauth::{ use crate::exchange_graph::types::{EventBodyFormat, MailboxKind, Surfaces, synthetic_account_id}; use crate::jmap::http::RetryPolicy; use crate::logging::LEVEL_DEFAULT; +use crate::net::CertOverride; use crate::sync::{CommonConfig, Summary, TypeCounts}; #[derive(Debug, Clone)] @@ -68,11 +69,11 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result Result Result { +fn acquire_with_flow(auth: &GraphAuth) -> Result { let flow = match auth { GraphAuth::PreAcquired { token } => OAuthFlow::PreAcquired { token: token.clone(), @@ -295,7 +290,7 @@ fn acquire_with_flow(auth: &GraphAuth, allow_invalid_certs: bool) -> Result Result { @@ -379,7 +374,6 @@ fn spawn_token_refresher( client: &GraphClient, auth: &GraphAuth, initial: &AcquiredToken, - allow_invalid_certs: bool, logger: crate::logging::Logger, ) -> Option { let (authority, client_id) = match auth { @@ -416,12 +410,7 @@ fn spawn_token_refresher( break; } } - match refresh_access_token( - &authority, - &client_id, - &refresh_token, - allow_invalid_certs, - ) { + match refresh_access_token(&authority, &client_id, &refresh_token) { Ok(tok) => { client.set_bearer(tok.access_token.clone()); if let Some(new_refresh) = tok.refresh_token { diff --git a/src/sync/mod.rs b/src/sync/mod.rs index e01de3b..a9e7178 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -41,6 +42,7 @@ pub(crate) fn table_name(ty: ObjectType) -> &'static str { use crate::jmap::account::AccountSelector; use crate::jmap::http::{Auth, HttpClient, RetryPolicy}; use crate::logging::Logger; +use crate::net::CertOverride; use crate::types::ObjectType; pub struct CommonConfig { @@ -134,7 +136,7 @@ impl Context { let client = HttpClient::new( connect.auth.clone(), RetryPolicy::new(common.max_retries), - common.allow_invalid_certs, + CertOverride::for_url(common.allow_invalid_certs, &connect.url), ); Ok(Context { conn, diff --git a/tests/live_stalwart.rs b/tests/live_stalwart.rs index ccf8977..a97a893 100644 --- a/tests/live_stalwart.rs +++ b/tests/live_stalwart.rs @@ -11,6 +11,7 @@ mod seeder; use inbuxa_migrate::jmap::account::{self, AccountSelector}; use inbuxa_migrate::jmap::http::{Auth, HttpClient, RetryPolicy}; use inbuxa_migrate::jmap::session::Session; +use inbuxa_migrate::net::CertOverride; use integration::stalwart::shared as shared_stalwart; fn admin_client() -> HttpClient { @@ -20,7 +21,7 @@ fn admin_client() -> HttpClient { password: seeder::ADMIN_PASSWORD.into(), }, RetryPolicy::new(5), - true, + CertOverride::for_url(true, shared_stalwart().base_url()), ) } diff --git a/tests/mock_dav.rs b/tests/mock_dav.rs index 5acc719..6f032b6 100644 --- a/tests/mock_dav.rs +++ b/tests/mock_dav.rs @@ -13,6 +13,7 @@ use inbuxa_migrate::dav::parse::{parse_multistatus, strip_ascii_control_chars}; use inbuxa_migrate::dav::xml; use inbuxa_migrate::jmap::error::JmapError; use inbuxa_migrate::jmap::http::{Auth, RetryPolicy}; +use inbuxa_migrate::net::CertOverride; fn client(retries: u32) -> DavClient { DavClient::new( @@ -21,7 +22,7 @@ fn client(retries: u32) -> DavClient { password: "p".into(), }, RetryPolicy::new(retries), - false, + CertOverride::none(), ) } diff --git a/tests/mock_exchange_ews.rs b/tests/mock_exchange_ews.rs index 539974e..c070fcc 100644 --- a/tests/mock_exchange_ews.rs +++ b/tests/mock_exchange_ews.rs @@ -22,6 +22,7 @@ use inbuxa_migrate::exchange_ews::xml::{ get_item_body, sync_folder_items_body, }; use inbuxa_migrate::jmap::http::{Auth, RetryPolicy}; +use inbuxa_migrate::net::CertOverride; use mockito::Matcher; const TXT_XML: &str = "text/xml; charset=utf-8"; @@ -33,7 +34,7 @@ fn client(retries: u32) -> EwsClient { token: "t".to_owned(), }, RetryPolicy::new(retries), - false, + CertOverride::none(), ) } @@ -63,7 +64,7 @@ fn autodiscover_v2_returns_global_endpoint() { let _ = server; let url = "https://outlook.office365.com/EWS/Exchange.asmx"; assert!(inbuxa_migrate::exchange_ews::autodiscover::is_fully_qualified_ews_url(url)); - let r = discover(Some(url), None, None, false).unwrap(); + let r = discover(Some(url), None, None, &CertOverride::none()).unwrap(); assert_eq!(r.source, DiscoverySource::SuppliedUrl); assert_eq!(r.ews_url, url); } diff --git a/tests/mock_exchange_graph.rs b/tests/mock_exchange_graph.rs index 51af475..63ffae2 100644 --- a/tests/mock_exchange_graph.rs +++ b/tests/mock_exchange_graph.rs @@ -21,6 +21,7 @@ use inbuxa_migrate::exchange_graph::recurrence::convert_patterned_recurrence; use inbuxa_migrate::exchange_graph::retry::{HttpClass, classify_http_status}; use inbuxa_migrate::exchange_graph::types::Surfaces; use inbuxa_migrate::jmap::http::RetryPolicy; +use inbuxa_migrate::net::CertOverride; use mockito::{Matcher, Server}; use serde_json::json; @@ -28,7 +29,11 @@ static INIT: Once = Once::new(); fn client_with_retries(retries: u32) -> GraphClient { INIT.call_once(|| {}); - GraphClient::new("BEARER".to_owned(), RetryPolicy::new(retries), false) + GraphClient::new( + "BEARER".to_owned(), + RetryPolicy::new(retries), + CertOverride::none(), + ) } fn url_message_collection(server_url: &str, folder: &str, top: usize) -> String { @@ -1681,7 +1686,11 @@ fn graph_client_retries_after_401_when_bearer_is_swapped() { .expect(1) .create(); let base = server.url(); - let client = GraphClient::new("EXPIRED".to_owned(), RetryPolicy::new(0), false); + let client = GraphClient::new( + "EXPIRED".to_owned(), + RetryPolicy::new(0), + CertOverride::none(), + ); let url = format!("{base}/me"); let err = client.get(&url, Accept::Json).unwrap_err(); assert!(matches!(err, GraphError::Auth(_))); diff --git a/tests/mock_jmap.rs b/tests/mock_jmap.rs index b0ab867..22349ce 100644 --- a/tests/mock_jmap.rs +++ b/tests/mock_jmap.rs @@ -17,6 +17,7 @@ use inbuxa_migrate::jmap::session::{Limits, Session}; use inbuxa_migrate::jmap::wire::JmapId; use inbuxa_migrate::jmap::wire::identity::Identity; use inbuxa_migrate::jmap::wire::mailbox::Mailbox; +use inbuxa_migrate::net::CertOverride; use serde_json::json; fn client(retries: u32) -> HttpClient { @@ -26,7 +27,7 @@ fn client(retries: u32) -> HttpClient { password: "p".into(), }, RetryPolicy::new(retries), - false, + CertOverride::none(), ) } diff --git a/tests/sync_jmap.rs b/tests/sync_jmap.rs index 842dab1..fa0b550 100644 --- a/tests/sync_jmap.rs +++ b/tests/sync_jmap.rs @@ -16,6 +16,7 @@ use inbuxa_migrate::jmap::http::{Auth, HttpClient, RetryPolicy}; use inbuxa_migrate::jmap::request::Request; use inbuxa_migrate::jmap::session::Session; use inbuxa_migrate::logging::Logger; +use inbuxa_migrate::net::CertOverride; use inbuxa_migrate::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig}; use integration::stalwart::shared as shared_stalwart; use rusqlite::Connection; @@ -785,7 +786,11 @@ fn export_inlines_contact_and_event_blobs_instead_of_blob_ids() { assert_eq!(counts.failed, 0, "{name} had no failures: {counts:?}"); } - let client = HttpClient::new(basic("test6"), RetryPolicy::new(5), true); + let client = HttpClient::new( + basic("test6"), + RetryPolicy::new(5), + CertOverride::for_url(true, base_url()), + ); let session = Session::discover(&client, base_url()).expect("discover target session"); let api = session.api_url.clone(); @@ -1047,7 +1052,11 @@ fn live_burst_exceeds_concurrent_requests_and_recovers() { let fx = seeder::provision(base_url()).expect("provision"); let acc = fx.account("test1").expect("test1"); - let client = HttpClient::new(basic("test1"), RetryPolicy::new(20), true); + let client = HttpClient::new( + basic("test1"), + RetryPolicy::new(20), + CertOverride::for_url(true, base_url()), + ); let session = Session::discover(&client, base_url()).expect("discover session"); let server_limits = session.core_limits().expect("core limits"); @@ -1148,7 +1157,7 @@ impl JmapSettingsGuard { password: seeder::ADMIN_PASSWORD.to_owned(), }, RetryPolicy::new(5), - true, + CertOverride::for_url(true, base_url()), ); let session = Session::discover(&admin, base_url()).expect("admin discover"); let admin_account = session @@ -1269,7 +1278,11 @@ fn live_blob_quota_429_triggers_retry_after_then_succeeds() { ); let _ttl_guard = JmapSettingsGuard::override_settings(updates); - let client = HttpClient::new(basic("test1"), RetryPolicy::new(20), true); + let client = HttpClient::new( + basic("test1"), + RetryPolicy::new(20), + CertOverride::for_url(true, base_url()), + ); let session = Session::discover(&client, base_url()).expect("discover session"); let limits = session.core_limits().expect("core limits"); client.set_limits(&limits); @@ -1346,7 +1359,11 @@ fn import_delta_propagates_email_keyword_change_via_changes() { .expect("an unflagged email exists in the archive") }; - let client = HttpClient::new(basic("test1"), RetryPolicy::new(5), true); + let client = HttpClient::new( + basic("test1"), + RetryPolicy::new(5), + CertOverride::for_url(true, base_url()), + ); let session = Session::discover(&client, base_url()).expect("session discovered"); let account = account::resolve( &AccountSelector::Id(acc.account_id.clone()),