feat(mailer-smtp): implement in-process SMTP server and management IPC integration
This commit is contained in:
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
169
rust/crates/mailer-smtp/src/validation.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! SMTP-level validation utilities.
|
||||
//!
|
||||
//! Address parsing, EHLO hostname validation, and header injection detection.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Regex for basic email address format validation.
|
||||
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
|
||||
});
|
||||
|
||||
/// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal).
|
||||
/// Currently unused in favor of a more permissive check, but available
|
||||
/// for strict validation if needed.
|
||||
#[allow(dead_code)]
|
||||
static EHLO_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words
|
||||
Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap()
|
||||
});
|
||||
|
||||
/// Validate an email address for basic SMTP format.
|
||||
///
|
||||
/// Returns `true` if the address has a valid-looking format.
|
||||
/// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`.
|
||||
pub fn is_valid_smtp_address(address: &str) -> bool {
|
||||
// Empty address is valid for MAIL FROM (bounce)
|
||||
if address.is_empty() {
|
||||
return true;
|
||||
}
|
||||
EMAIL_RE.is_match(address)
|
||||
}
|
||||
|
||||
/// Validate an EHLO/HELO hostname.
|
||||
///
|
||||
/// Returns `true` if the hostname looks syntactically valid.
|
||||
/// We are permissive because real-world SMTP clients send all kinds of values.
|
||||
pub fn is_valid_ehlo_hostname(hostname: &str) -> bool {
|
||||
if hostname.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Be permissive — most SMTP servers accept anything non-empty.
|
||||
// Only reject obviously malicious patterns.
|
||||
if hostname.len() > 255 {
|
||||
return false;
|
||||
}
|
||||
if contains_header_injection(hostname) {
|
||||
return false;
|
||||
}
|
||||
// Must not contain null bytes
|
||||
if hostname.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check for SMTP header injection attempts.
|
||||
///
|
||||
/// Returns `true` if the input contains characters that could be used
|
||||
/// for header injection (bare CR/LF).
|
||||
pub fn contains_header_injection(input: &str) -> bool {
|
||||
input.contains('\r') || input.contains('\n')
|
||||
}
|
||||
|
||||
/// Validate the size parameter from MAIL FROM.
|
||||
///
|
||||
/// Returns the parsed size if valid and within the max, or an error message.
|
||||
pub fn validate_size_param(value: &str, max_size: u64) -> Result<u64, String> {
|
||||
let size: u64 = value
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid SIZE value: {value}"))?;
|
||||
if size > max_size {
|
||||
return Err(format!(
|
||||
"message size {size} exceeds maximum {max_size}"
|
||||
));
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// Extract the domain part from an email address.
|
||||
pub fn extract_domain(address: &str) -> Option<&str> {
|
||||
if address.is_empty() {
|
||||
return None;
|
||||
}
|
||||
address.rsplit_once('@').map(|(_, domain)| domain)
|
||||
}
|
||||
|
||||
/// Normalize an email address by lowercasing the domain part.
|
||||
pub fn normalize_address(address: &str) -> String {
|
||||
if address.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match address.rsplit_once('@') {
|
||||
Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()),
|
||||
None => address.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(is_valid_smtp_address("user@example.com"));
|
||||
assert!(is_valid_smtp_address("user+tag@sub.example.com"));
|
||||
assert!(is_valid_smtp_address("a@b.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_address_valid() {
|
||||
assert!(is_valid_smtp_address(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email() {
|
||||
assert!(!is_valid_smtp_address("no-at-sign"));
|
||||
assert!(!is_valid_smtp_address("@no-local.com"));
|
||||
assert!(!is_valid_smtp_address("user@"));
|
||||
assert!(!is_valid_smtp_address("user@nodot"));
|
||||
assert!(!is_valid_smtp_address("has space@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_ehlo() {
|
||||
assert!(is_valid_ehlo_hostname("mail.example.com"));
|
||||
assert!(is_valid_ehlo_hostname("localhost"));
|
||||
assert!(is_valid_ehlo_hostname("[127.0.0.1]"));
|
||||
assert!(is_valid_ehlo_hostname("[IPv6:::1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ehlo() {
|
||||
assert!(!is_valid_ehlo_hostname(""));
|
||||
assert!(!is_valid_ehlo_hostname("host\r\nname"));
|
||||
assert!(!is_valid_ehlo_hostname(&"a".repeat(256)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_injection() {
|
||||
assert!(contains_header_injection("test\r\nBcc: evil@evil.com"));
|
||||
assert!(contains_header_injection("test\ninjection"));
|
||||
assert!(contains_header_injection("test\rinjection"));
|
||||
assert!(!contains_header_injection("normal text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_size_param() {
|
||||
assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345));
|
||||
assert!(validate_size_param("99999999", 1_000).is_err());
|
||||
assert!(validate_size_param("notanumber", 1_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_domain() {
|
||||
assert_eq!(extract_domain("user@example.com"), Some("example.com"));
|
||||
assert_eq!(extract_domain(""), None);
|
||||
assert_eq!(extract_domain("nodomain"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_address() {
|
||||
assert_eq!(
|
||||
normalize_address("User@EXAMPLE.COM"),
|
||||
"User@example.com"
|
||||
);
|
||||
assert_eq!(normalize_address(""), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user