From 48377542458c81554662347534680c92d759a85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:25:21 +0100 Subject: [PATCH] feat: Support addresses using domain literals (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the incoming email comes from address that uses domain literals `[]` or `[IPv6:]`, skip DKIM verification and instead check IP alignment with originating IP from XFORWARD command. Signed-off-by: Jagoda Ślązak --- filtermail/src/dkim_verifier.rs | 15 ++----- filtermail/src/error.rs | 3 ++ filtermail/src/inbound.rs | 25 ++++++++++-- filtermail/src/smtp_server.rs | 25 +++++++++++- filtermail/src/utils.rs | 70 +++++++++++++++++++++++++++------ 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/filtermail/src/dkim_verifier.rs b/filtermail/src/dkim_verifier.rs index c8ffdaf4..5ec7e18d 100644 --- a/filtermail/src/dkim_verifier.rs +++ b/filtermail/src/dkim_verifier.rs @@ -1,4 +1,3 @@ -use crate::utils::get_domain_from_address; use hickory_resolver::name_server::TokioConnectionProvider; use hickory_resolver::{Name, TokioResolver}; use lru::LruCache; @@ -135,9 +134,9 @@ impl DkimVerifier { Ok(Self { resolver, config }) } - /// Verifies the DKIM signature of a raw email message and alignment with the domain of - /// provided `From` address. - pub async fn verify(&self, raw_mail: &[u8], from_address: &str) -> Result<(), String> { + /// Verifies the DKIM signature of a raw email message and its alignment with the provided + /// domain. + pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { let (headers, body_start) = { use viadkim::{FieldBody, FieldName, HeaderField}; @@ -166,12 +165,6 @@ impl DkimVerifier { (viadkim_headers, body_start) }; - let Some(from_domain) = get_domain_from_address(from_address) else { - return Err("501 Invalid From address".to_string()); - }; - - log::debug!("`From` header domain: {from_domain}"); - let Some(mut verifier) = viadkim::Verifier::verify_header(&self.resolver, &headers, &self.config).await else { @@ -204,7 +197,7 @@ impl DkimVerifier { if !signature .domain .to_string() - .eq_ignore_ascii_case(&from_domain) + .eq_ignore_ascii_case(from_domain) { log::debug!( "Signature {}: Domain different than in From header, skipping", diff --git a/filtermail/src/error.rs b/filtermail/src/error.rs index b51d6c1c..b8593634 100644 --- a/filtermail/src/error.rs +++ b/filtermail/src/error.rs @@ -17,6 +17,8 @@ pub enum Error { context: String, raw_smtp_answer: String, }, + #[error("Invalid email address: {0}")] + InvalidEmailAddress(String), } impl Error { @@ -28,6 +30,7 @@ impl Error { raw_smtp_answer, .. } => raw_smtp_answer.clone(), Error::TruncatedHeader => self.to_string(), + Error::InvalidEmailAddress(address) => format!("500 Invalid email address: {address}"), _ => "451 Local error".to_string(), } } diff --git a/filtermail/src/inbound.rs b/filtermail/src/inbound.rs index 82757c6b..f849f0a1 100644 --- a/filtermail/src/inbound.rs +++ b/filtermail/src/inbound.rs @@ -6,9 +6,10 @@ use crate::dkim_verifier::DkimVerifier; use crate::message::{check_encrypted, is_securejoin}; pub use crate::smtp_server::Envelope; use crate::smtp_server::SmtpHandler; -use crate::utils::extract_address; +use crate::utils::{AddressDomain, extract_address}; use async_trait::async_trait; use mailparse::{MailHeaderMap, parse_mail}; +use std::str::FromStr; /// Handler for incoming SMTP messages. pub struct IncomingBeforeQueueHandler { @@ -50,9 +51,25 @@ impl SmtpHandler for IncomingBeforeQueueHandler { return Err(format!("500 Invalid FROM header: {from_header}")); }; - self.dkim_verifier - .verify(&envelope.data, &from_addr) - .await?; + let from_domain = AddressDomain::from_str(&from_addr).map_err(|e| e.smtp_response())?; + + match from_domain { + AddressDomain::Literal(ip) => { + if !envelope.origin_ip.eq_ignore_ascii_case(&ip) { + log::warn!( + "Received invalid origin address: {ip}, actual: {}", + envelope.origin_ip + ); + return Err(format!( + "500 Invalid FROM domain literal: {ip} does not match origin IP {}", + envelope.origin_ip + )); + } + } + AddressDomain::Name(domain) => { + self.dkim_verifier.verify(&envelope.data, &domain).await?; + } + } let mail_encrypted = check_encrypted(&message, false); log::debug!("mail_encrypted: {mail_encrypted}"); diff --git a/filtermail/src/smtp_server.rs b/filtermail/src/smtp_server.rs index e5fd0198..7efcccf2 100644 --- a/filtermail/src/smtp_server.rs +++ b/filtermail/src/smtp_server.rs @@ -10,6 +10,7 @@ use tokio::net::{TcpListener, TcpStream}; #[derive(Debug, Clone)] pub struct Envelope { pub mail_from: String, + pub origin_ip: String, pub rcpt_to: Vec, pub data: Vec, } @@ -85,6 +86,7 @@ where let mut envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; @@ -106,8 +108,13 @@ where log::debug!("Received: {cmd}"); - if cmd.to_uppercase().starts_with("HELO") || cmd.to_uppercase().starts_with("EHLO") { - writer.write_all(b"250 OK\r\n").await?; + if cmd.to_uppercase().starts_with("HELO") { + writer.write_all(b"250-filtermail\r\n250 OK\r\n").await?; + writer.flush().await?; + } else if cmd.to_uppercase().starts_with("EHLO") { + writer + .write_all(b"250-filtermail\r\n250-XFORWARD ADDR\r\n250 OK\r\n") + .await?; writer.flush().await?; } else if cmd.to_uppercase().starts_with("MAIL FROM:") { if let Some(from) = extract_address(cmd) { @@ -187,9 +194,22 @@ where envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; + } else if cmd.to_uppercase().starts_with("XFORWARD") { + // https://www.postfix.org/XFORWARD_README.html + if let Some(addr_part) = cmd + .split_whitespace() + .find(|part| part.to_uppercase().starts_with("ADDR=")) + && let Some(ip) = addr_part.strip_prefix("ADDR=") + { + let ip = ip.to_lowercase(); + envelope.origin_ip = ip.strip_prefix("ipv6:").unwrap_or(&ip).to_string(); + writer.write_all(b"250 OK\r\n").await?; + writer.flush().await?; + } } else if cmd.to_uppercase().starts_with("QUIT") { writer.write_all(b"221 OK\r\n").await?; writer.flush().await?; @@ -197,6 +217,7 @@ where } else if cmd.to_uppercase().starts_with("RSET") { envelope = Envelope { mail_from: String::new(), + origin_ip: String::new(), rcpt_to: Vec::new(), data: Vec::new(), }; diff --git a/filtermail/src/utils.rs b/filtermail/src/utils.rs index 3b221480..230886e4 100644 --- a/filtermail/src/utils.rs +++ b/filtermail/src/utils.rs @@ -1,4 +1,5 @@ use mailparse::MailAddr; +use std::str::FromStr; /// Extracts the first email address found in SMTP command or email header. /// @@ -26,14 +27,51 @@ pub fn extract_address(input: &str) -> Option { }) } -pub fn get_domain_from_address(address: &str) -> Option { - let parts: Vec<&str> = address.split('@').collect(); - if parts.len() == 2 - && let Some(domain) = parts.get(1) - { - Some(domain.to_string()) - } else { - None +/// Domain part of an email address, either a domain-literal (IP address in square brackets with +/// optional protocol prefix) or a regular domain name. +#[derive(Debug, PartialEq, Eq)] +pub enum AddressDomain { + /// Domain literal, e.g. + /// - `192.0.2.0` in `test@[192.0.2.0]`, + /// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`. + Literal(String), + /// Regular domain name, e.g. `example.org` in `test@example.org`. + Name(String), +} + +impl FromStr for AddressDomain { + type Err = crate::error::Error; + + /// Extracts the domain part from an email address and returns it as an [`AddressDomain`]. + /// + /// Returns an [`Error`] if `address` is not a valid email address. + /// + /// [`Error`]: crate::error::Error + fn from_str(address: &str) -> Result { + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() == 2 + && let Some(domain) = parts.get(1) + { + // domain literals + if domain.starts_with('[') && domain.ends_with(']') { + let mut address_trimmed = domain + .get(1..domain.len() - 1) + .unwrap_or(domain) + .to_lowercase(); + + address_trimmed = address_trimmed + .strip_prefix("ipv6:") + .unwrap_or(&address_trimmed) + .to_string(); + + return Ok(AddressDomain::Literal(address_trimmed.to_string())); + } + Ok(AddressDomain::Name(domain.to_string())) + } else { + Err(crate::error::Error::InvalidEmailAddress( + address.to_string(), + )) + } } } @@ -51,18 +89,26 @@ mod tests { #[case("mail from:", Some("t4@example.org".to_string()))] #[case("Foo Bar ", Some("t5@example.org".to_string()))] #[case("t6@example.org", Some("t6@example.org".to_string()))] + #[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))] + #[case("", Some("t7@[192.0.2.0]".to_string()))] + // This is a bug in mailparse, it refuses to parse IPv6 without "<>" around. + // https://github.com/staktrace/mailparse/issues/137 + #[case("t8@[IPv6:2001:db8::1]", None)] + #[case("", Some("t8@[ipv6:2001:db8::1]".to_string()))] fn test_extract_address(#[case] input: &str, #[case] expected: Option) { let result = extract_address(input); assert_eq!(result, expected) } #[rstest] - #[case("t1@example.org", Some("example.org".to_string()))] - #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some("example.net".to_string()))] + #[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))] + #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))] + #[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))] + #[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))] #[case("invalid", None)] #[case("invalid@address@com", None)] - fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { - let result = get_domain_from_address(input); + fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { + let result = AddressDomain::from_str(input).ok(); assert_eq!(result, expected); } }