feat: Support addresses using domain literals (#42)

If the incoming email comes from address that uses
domain literals `[<ipv4>]` or `[IPv6:<ipv6>]`,
skip DKIM verification and instead check IP
alignment with originating IP from XFORWARD
command.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-02-16 18:25:21 +01:00
committed by GitHub
parent a096d0550f
commit 4837754245
5 changed files with 109 additions and 29 deletions
+4 -11
View File
@@ -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",
+3
View File
@@ -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(),
}
}
+21 -4
View File
@@ -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}");
+23 -2
View File
@@ -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<String>,
pub data: Vec<u8>,
}
@@ -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(),
};
+58 -12
View File
@@ -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<String> {
})
}
pub fn get_domain_from_address(address: &str) -> Option<String> {
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<Self, Self::Err> {
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:<t4@example.org>", Some("t4@example.org".to_string()))]
#[case("Foo Bar <t5@example.org>", 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("<t7@[192.0.2.0]>", 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("<t8@[IPv6:2001:db8::1]>", Some("t8@[ipv6:2001:db8::1]".to_string()))]
fn test_extract_address(#[case] input: &str, #[case] expected: Option<String>) {
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<String>) {
let result = get_domain_from_address(input);
fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option<AddressDomain>) {
let result = AddressDomain::from_str(input).ok();
assert_eq!(result, expected);
}
}