mirror of
https://github.com/chatmail/relay.git
synced 2026-08-15 04:50:50 +00:00
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:
committed by
GitHub
parent
a096d0550f
commit
4837754245
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user