diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index b9f4acf..add5c79 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -467,18 +467,56 @@ impl Server { { return; } + self.audit_send_as_record( + token, + as_account_id, + address, + ("EmailSubmission", Id::from(submission_id).to_string()), + submission_account_id, + ) + .await; + } + + /// MA-D0a over SMTP submission: the same record, naming the queued + /// message. The caller has already resolved the address and left out + /// the sender's own account and a locked account's delegate. + pub async fn audit_send_as_queued( + &self, + token: &AccessToken, + as_account_id: u32, + queue_id: u64, + address: &str, + ) { + self.audit_send_as_record( + token, + as_account_id, + address, + ("QueuedMessage", format!("{queue_id:x}")), + token.account_id(), + ) + .await; + } + + async fn audit_send_as_record( + &self, + token: &AccessToken, + as_account_id: u32, + address: &str, + (kind, id): (&str, String), + from_account_id: u32, + ) { let actor = self.audit_actor(token).await; let tenant_id = self .account(as_account_id) .await .ok() .and_then(|account| account.id_tenant); - let details = if submission_account_id == as_account_id { + let details = if from_account_id == as_account_id { format!("Sent as {address}") } else { format!( "Sent as {address}, from {}", - self.audit_account_name(submission_account_id).await + self.audit_account_name(from_account_id).await ) }; self.audit_note(Record { @@ -488,8 +526,8 @@ impl Server { remote_ip: None, action: Action::Create, target: Target { - kind: "EmailSubmission".into(), - id: Some(Id::from(submission_id).to_string()), + kind: kind.into(), + id: Some(id), name: Some(address.to_string()), account_id: Some(as_account_id), tenant_id, diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 96c6ccb..4514532 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -9,7 +9,7 @@ use crate::{inbound::auth::SaslToken, queue::QueueId}; use common::{ Inner, Server, - auth::AccountInfo, + auth::{AccessToken, AccountInfo}, config::smtp::auth::VerifyStrategy, network::{ServerInstance, asn::AsnGeoLookupResult}, }; @@ -80,6 +80,9 @@ pub struct SessionData { pub message: Vec, pub authenticated_as: Option, + // inbuxa: MA-S, AL-8: the signed-in token, for its delegations' sendAs + // and the audit record of a send as someone else (MA-D0a, AL-9) + pub access_token: Option, pub auth_errors: usize, pub priority: i16, @@ -195,6 +198,7 @@ impl SessionData { mailflow_queue: None, journal_marks: Vec::new(), journal_added: Vec::new(), + access_token: None, } } } @@ -323,6 +327,7 @@ impl SessionData { mailflow_queue: None, journal_marks: Vec::new(), journal_added: Vec::new(), + access_token: None, } } } diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index d2dc494..d7ecd26 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -12,7 +12,7 @@ use common::{ network::{SessionStream, legacy::LegacyProtocol}, }; use directory::Credentials; -use mail_parser::decoders::base64::base64_decode; +use mail_parser::{HeaderName, HeaderValue, decoders::base64::base64_decode}; use registry::schema::enums::Permission; use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString}; use trc::AuthEvent; @@ -148,13 +148,18 @@ impl Session { } let result = match result { - Ok(access_token) => self.server.account_info(access_token.account_id()).await, + Ok(access_token) => self + .server + .account_info(access_token.account_id()) + .await + .map(|account_info| (account_info, access_token)), Err(err) => Err(err), }; match result { - Ok(account_info) => { + Ok((account_info, access_token)) => { self.data.authenticated_as = account_info.into(); + self.data.access_token = access_token.into(); self.eval_post_auth_params().await; self.write(b"235 2.7.0 Authentication succeeded.\r\n") .await?; @@ -262,4 +267,102 @@ impl Session { pub fn authenticated_emails(&self) -> &[String] { self.data.authenticated_as.as_ref().unwrap().addresses() } + + /// inbuxa: MA-S, AL-8: whether the signed-in account holds a current + /// delegation with sendAs into the account that owns `address`: a + /// shared mailbox, or a locked account handed to it. The address must + /// be the owner's own; a catch-all doesn't count. + pub async fn delegate_sends_as(&self, address: &str) -> bool { + let Some(access_token) = &self.data.access_token else { + return false; + }; + match self.server.account_id_from_email(address, false).await { + Ok(Some(owner_id)) => access_token + .delegation(owner_id) + .is_some_and(|delegation| delegation.send_as), + _ => false, + } + } + + /// inbuxa: MA-S3: when the message goes out as a shared mailbox the + /// signed-in account was assigned to, the first From or Reply-To + /// address that isn't the mailbox's own, as JMAP refuses it. + pub async fn shared_mailbox_foreign_address( + &self, + message: &mail_parser::Message<'_>, + ) -> Option { + let access_token = self.data.access_token.as_ref()?; + let mail_from = self.data.mail_from.as_ref()?; + let owner_id = self + .server + .account_id_from_email(&mail_from.address_lcase, false) + .await + .ok()??; + if owner_id == access_token.account_id() || !access_token.delegated_shared_mailbox(owner_id) + { + return None; + } + let named = message + .headers() + .iter() + .filter(|header| matches!(header.name, HeaderName::From | HeaderName::ReplyTo)) + .filter_map(|header| match &header.value { + HeaderValue::Address(address) => Some(address), + _ => None, + }) + .flat_map(|address| address.iter()) + .filter_map(|addr| addr.address().map(str::to_string)) + .collect::>(); + for address in named { + if self + .server + .account_id_from_email(&address, true) + .await + .ok() + .flatten() + != Some(owner_id) + { + return Some(address); + } + } + None + } + + /// inbuxa: MA-D0a, AL-9: a message queued from an address that isn't the + /// signed-in account's own is recorded: who sent it and as whom. A + /// locked account's delegate's send is that delegation's record. + pub async fn audit_queued_send_as(&self, queue_id: u64) { + let (Some(access_token), Some(mail_from)) = (&self.data.access_token, &self.data.mail_from) + else { + return; + }; + let address = mail_from.address_lcase.as_str(); + if address.is_empty() { + return; + } + let Ok(Some(as_account_id)) = self.server.account_id_from_email(address, true).await else { + return; + }; + if as_account_id == access_token.account_id() { + return; + } + match access_token.delegation(as_account_id) { + Some(delegation) if delegation.kind.is_lock() => { + self.server + .audit_delegate( + access_token, + as_account_id, + delegation.access.as_str(), + Some("SMTP submission"), + None, + ) + .await; + } + _ => { + self.server + .audit_send_as_queued(access_token, as_account_id, queue_id, address) + .await; + } + } + } } diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index ed0c5e0..9c71b53 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -74,6 +74,16 @@ impl Session { } }; + // inbuxa: MA-S3: a shared mailbox's people send only as it over SMTP + // too, so the answer comes back to the mailbox + if let Some(address) = self.shared_mailbox_foreign_address(&parsed_message).await { + return format!( + "550 5.7.1 A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one.\r\n" + ) + .into_bytes() + .into(); + } + // Authenticate message let mut auth_message = AuthenticatedMessage::from_parsed( &parsed_message, @@ -900,6 +910,8 @@ impl Session { { self.state = State::Accepted(queue_id); self.data.messages_sent += 1; + // inbuxa: MA-D0a, AL-9: a send as someone else is recorded + self.audit_queued_send_as(queue_id).await; if let (Some(draft), Some((sender, recipients, size))) = (held_draft, held_envelope) { self.record_held(queue_id, draft, sender, recipients, size).await; diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index b708878..6242416 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -259,6 +259,9 @@ impl Session { ) .await .unwrap_or(false) + // inbuxa: MA-S, AL-8: a delegate allowed to send as the + // account that owns the address, as JMAP allows + && !self.delegate_sends_as(address_lcase).await { trc::event!( Smtp(SmtpEvent::MailFromUnauthorized), diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs index 84ece5c..371a819 100644 --- a/crates/smtp/src/inbound/session.rs +++ b/crates/smtp/src/inbound/session.rs @@ -505,6 +505,7 @@ impl Session { self.data.helo_domain.clear(); self.data.spf_ehlo = None; self.data.authenticated_as = None; + self.data.access_token = None; } #[inline(always)] diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs index ea2a3e5..d5b1166 100644 --- a/tests/src/system/account_lock.rs +++ b/tests/src/system/account_lock.rs @@ -12,6 +12,7 @@ use crate::{ jmap::mail::submission::{ MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server, }, + smtp::session::TestSession, utils::{ account::Account, dns::DnsCache, @@ -448,13 +449,14 @@ pub async fn test(test: &mut TestServer) { "AL-9: the delegate's access and changes weren't recorded: {query}" ); - shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await; + shared_mailbox(test, admin, &mut smtp_rx, &mut lmtp).await; } /// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own /// kind. No reason is needed, more people fit, its Sieve replies go out, /// only what is sent as it is recorded, and it sends only as itself. async fn shared_mailbox( + test: &TestServer, admin: &Account, smtp_rx: &mut tokio::sync::mpsc::Receiver, lmtp: &mut SmtpConnection, @@ -594,6 +596,65 @@ async fn shared_mailbox( .collect::>(); assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}"); + // Over SMTP submission too: the agent signs in as itself and sends as + // support@, a desk without sendAs can't, and the send is recorded + let smtp_session = || { + let mut session = test.new_mta_session(); + session.stream.tls = true; + session + }; + let mut session = smtp_session(); + session.eval_session_params().await; + session.ehlo("agent.example.com").await; + session.auth_plain("agent@example.com", "agent-secret-5520", "235 2.7.0").await; + session + .send_message( + "support@example.com", + &["carol@remote.org"], + "From: support@example.com\r\nTo: carol@remote.org\r\nSubject: Re: Over SMTP\r\n\r\nThanks.\r\n", + "250", + ) + .await; + assert_message_delivery( + smtp_rx, + MockMessage::new("", [""], "@Re: Over SMTP"), + ) + .await; + // MA-S3: and the reply still can't be steered to the agent + session + .send_message( + "support@example.com", + &["carol@remote.org"], + "From: support@example.com\r\nReply-To: agent@example.com\r\nTo: carol@remote.org\r\nSubject: Write to me\r\n\r\nHi.\r\n", + "550 5.7.1", + ) + .await; + expect_nothing(smtp_rx).await; + let mut session = smtp_session(); + session.eval_session_params().await; + session.ehlo("desk.example.com").await; + session.auth_plain("desk0@example.com", "desk-secret-7781", "235 2.7.0").await; + session.mail_from("support@example.com", "501 5.5.4").await; + session.mail_from("desk0@example.com", "250").await; + let (_, query) = admin + .call( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), + "filter": {"actorId": agent.id_string(), "accountId": support_id}}), + ) + .await; + let (_, records) = admin + .call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]})) + .await; + let mut kinds = records["list"] + .as_array() + .unwrap() + .iter() + .map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string()) + .collect::>(); + kinds.sort(); + assert_eq!(kinds, ["EmailSubmission", "QueuedMessage"], "MA-D0a over SMTP: {records}"); + // Ending it needs no reason either let response = admin.lock_set(json!({"destroy": [support_id]})).await; assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");