use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
const MAX_HOSTNAME_LENGTH: usize = 253;
const MAX_LABEL_LENGTH: usize = 63;
#[derive(Debug, Clone)]
pub enum ValidationError {
InvalidHostname(String),
InvalidPort(String),
InvalidPath(String),
InvalidCipher(String),
InvalidProtocol(String),
SsrfAttempt(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidHostname(msg) => write!(f, "Invalid hostname: {}", msg),
Self::InvalidPort(msg) => write!(f, "Invalid port: {}", msg),
Self::InvalidPath(msg) => write!(f, "Invalid path: {}", msg),
Self::InvalidCipher(msg) => write!(f, "Invalid cipher: {}", msg),
Self::InvalidProtocol(msg) => write!(f, "Invalid protocol: {}", msg),
Self::SsrfAttempt(msg) => write!(f, "SSRF attempt detected: {}", msg),
}
}
}
impl std::error::Error for ValidationError {}
pub fn validate_hostname(hostname: &str) -> std::result::Result<(), ValidationError> {
if hostname.is_empty() {
return Err(ValidationError::InvalidHostname(
"Hostname cannot be empty".to_string(),
));
}
if hostname.len() > MAX_HOSTNAME_LENGTH {
return Err(ValidationError::InvalidHostname(format!(
"Hostname too long (max {} characters)",
MAX_HOSTNAME_LENGTH
)));
}
let dangerous_chars = [
'|', '&', ';', '$', '`', '\n', '\r', '<', '>', '(', ')', '{', '}', '\\', '\'', '"', ' ',
];
for ch in dangerous_chars.iter() {
if hostname.contains(*ch) {
return Err(ValidationError::InvalidHostname(format!(
"Hostname contains forbidden character: '{}'",
ch
)));
}
}
if hostname.contains('/') || hostname.contains('\\') {
return Err(ValidationError::InvalidHostname(
"Hostname cannot contain path separators".to_string(),
));
}
if hostname.parse::<IpAddr>().is_ok() {
return Ok(());
}
let labels: Vec<&str> = hostname.split('.').collect();
if labels.is_empty() {
return Err(ValidationError::InvalidHostname(
"Invalid hostname format".to_string(),
));
}
for label in labels {
if label.is_empty() || label.len() > MAX_LABEL_LENGTH {
return Err(ValidationError::InvalidHostname(format!(
"Label '{}' has invalid length (must be 1-{} characters)",
label, MAX_LABEL_LENGTH
)));
}
for (i, ch) in label.chars().enumerate() {
let is_first = i == 0;
let is_last = i == label.len() - 1;
let is_valid = ch.is_ascii_alphanumeric() || (ch == '-' && !is_first && !is_last);
if !is_valid {
return Err(ValidationError::InvalidHostname(format!(
"Label '{}' contains invalid character or invalid position for hyphen",
label
)));
}
}
}
Ok(())
}
pub fn validate_port(port: u16) -> std::result::Result<(), ValidationError> {
if port == 0 {
return Err(ValidationError::InvalidPort(
"Port must be between 1 and 65535".to_string(),
));
}
Ok(())
}
pub fn validate_cipher(cipher: &str) -> std::result::Result<(), ValidationError> {
if cipher.is_empty() {
return Err(ValidationError::InvalidCipher(
"Cipher cannot be empty".to_string(),
));
}
if cipher.len() > 512 {
return Err(ValidationError::InvalidCipher(
"Cipher string too long".to_string(),
));
}
for ch in cipher.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | ':' | '!' | '+' | '@' => continue,
_ => {
return Err(ValidationError::InvalidCipher(format!(
"Cipher contains invalid character: '{}'",
ch
)));
}
}
}
Ok(())
}
pub fn validate_starttls_protocol(protocol: &str) -> std::result::Result<(), ValidationError> {
const VALID_PROTOCOLS: &[&str] = &[
"smtp",
"pop3",
"imap",
"ftp",
"xmpp",
"xmpp-server",
"irc",
"postgres",
"mysql",
"lmtp",
"nntp",
"sieve",
"ldap",
];
if !VALID_PROTOCOLS.contains(&protocol) {
return Err(ValidationError::InvalidProtocol(format!(
"Unknown STARTTLS protocol: '{}'. Valid protocols: {}",
protocol,
VALID_PROTOCOLS.join(", ")
)));
}
Ok(())
}
pub fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => is_private_ipv4(ipv4),
IpAddr::V6(ipv6) => is_private_ipv6(ipv6),
}
}
fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_documentation()
|| ip.is_broadcast()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.octets()[0] >= 240
|| (ip.octets()[0] == 100 && (ip.octets()[1] & 0xC0) == 64)
|| (ip.octets()[0] == 0 && ip.octets()[1] == 0 && ip.octets()[2] == 0)
}
fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (ip.segments()[0] & 0xfe00) == 0xfc00
|| (ip.segments()[0] & 0xffc0) == 0xfe80
|| (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8)
}
pub fn validate_target(
target: &str,
allow_private_ips: bool,
) -> std::result::Result<(String, Option<u16>), ValidationError> {
if target.is_empty() {
return Err(ValidationError::InvalidHostname(
"Target cannot be empty".to_string(),
));
}
if target.len() > 300 {
return Err(ValidationError::InvalidHostname(
"Target string too long".to_string(),
));
}
let parts: Vec<&str> = target.split(':').collect();
let hostname = parts[0];
let port = if parts.len() > 1 {
parts[1]
.parse::<u16>()
.map_err(|_| ValidationError::InvalidPort("Invalid port format".to_string()))?
} else {
0
};
validate_hostname(hostname)?;
if port != 0 {
validate_port(port)?;
}
if !allow_private_ips
&& let Ok(ip) = hostname.parse::<IpAddr>()
&& is_private_ip(&ip) {
return Err(ValidationError::SsrfAttempt(format!(
"Access to private IP addresses is not allowed: {}",
ip
)));
}
let port_opt = if port != 0 { Some(port) } else { None };
Ok((hostname.to_string(), port_opt))
}
pub fn sanitize_path(path: &str, base_dir: &Path) -> std::result::Result<PathBuf, ValidationError> {
if path.contains('\0') {
return Err(ValidationError::InvalidPath(
"Path contains null byte".to_string(),
));
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(ValidationError::InvalidPath(
"Absolute paths are not allowed".to_string(),
));
}
if path.contains("..") {
return Err(ValidationError::InvalidPath(
"Path traversal sequences (..) are not allowed".to_string(),
));
}
#[cfg(windows)]
{
if path.len() >= 2 && path.as_bytes()[1] == b':' {
return Err(ValidationError::InvalidPath(
"Drive letters are not allowed".to_string(),
));
}
}
let full_path = base_dir.join(path);
let canonical_base = base_dir.canonicalize().map_err(|e| {
ValidationError::InvalidPath(format!("Cannot canonicalize base dir: {}", e))
})?;
let canonical_path = full_path.canonicalize().unwrap_or_else(|_| {
if let Some(parent) = full_path.parent()
&& let Ok(canonical_parent) = parent.canonicalize()
&& let Some(filename) = full_path.file_name() {
return canonical_parent.join(filename);
}
full_path.clone()
});
if !canonical_path.starts_with(&canonical_base) {
return Err(ValidationError::InvalidPath(format!(
"Path escapes base directory: {} not under {}",
canonical_path.display(),
canonical_base.display()
)));
}
Ok(canonical_path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_validate_hostname_valid() {
assert!(validate_hostname("example.com").is_ok());
assert!(validate_hostname("sub.example.com").is_ok());
assert!(validate_hostname("192.168.1.1").is_ok());
assert!(validate_hostname("localhost").is_ok());
assert!(validate_hostname("test-server-01.example.com").is_ok());
}
#[test]
fn test_validate_hostname_invalid() {
assert!(validate_hostname("example.com; rm -rf /").is_err());
assert!(validate_hostname("example.com|whoami").is_err());
assert!(validate_hostname("example.com`id`").is_err());
assert!(validate_hostname("example.com$(whoami)").is_err());
assert!(validate_hostname("../../etc/passwd").is_err());
assert!(validate_hostname("example.com/../../etc").is_err());
assert!(validate_hostname("example com").is_err());
assert!(validate_hostname("example\ncom").is_err());
assert!(validate_hostname("").is_err());
assert!(validate_hostname(&"a".repeat(300)).is_err());
}
#[test]
fn test_validate_port() {
assert!(validate_port(443).is_ok());
assert!(validate_port(1).is_ok());
assert!(validate_port(65535).is_ok());
assert!(validate_port(0).is_err());
}
#[test]
fn test_validate_cipher_valid() {
assert!(validate_cipher("AES256-GCM-SHA384").is_ok());
assert!(validate_cipher("ECDHE-RSA-AES256-GCM-SHA384").is_ok());
assert!(validate_cipher("HIGH:!aNULL:!MD5").is_ok());
assert!(validate_cipher("TLS_AES_256_GCM_SHA384").is_ok());
}
#[test]
fn test_validate_cipher_invalid() {
assert!(validate_cipher("AES256; rm -rf /").is_err());
assert!(validate_cipher("AES256|whoami").is_err());
assert!(validate_cipher("AES256`id`").is_err());
assert!(validate_cipher("").is_err());
}
#[test]
fn test_validate_starttls_protocol() {
assert!(validate_starttls_protocol("smtp").is_ok());
assert!(validate_starttls_protocol("imap").is_ok());
assert!(validate_starttls_protocol("invalid").is_err());
assert!(validate_starttls_protocol("smtp; whoami").is_err());
}
#[test]
fn test_is_private_ipv4() {
assert!(is_private_ip(&"127.0.0.1".parse().unwrap()));
assert!(is_private_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_private_ip(&"192.168.1.1".parse().unwrap()));
assert!(is_private_ip(&"169.254.1.1".parse().unwrap()));
assert!(is_private_ip(&"100.64.0.1".parse().unwrap()));
assert!(!is_private_ip(&"8.8.8.8".parse().unwrap()));
assert!(!is_private_ip(&"1.1.1.1".parse().unwrap()));
}
#[test]
fn test_is_private_ipv6() {
assert!(is_private_ip(&"::1".parse().unwrap()));
assert!(is_private_ip(&"fe80::1".parse().unwrap()));
assert!(is_private_ip(&"fc00::1".parse().unwrap()));
assert!(is_private_ip(&"2001:db8::1".parse().unwrap()));
assert!(!is_private_ip(&"2001:4860:4860::8888".parse().unwrap()));
}
#[test]
fn test_validate_target() {
assert!(validate_target("example.com", true).is_ok());
assert!(validate_target("example.com:443", true).is_ok());
assert!(validate_target("127.0.0.1", false).is_err());
assert!(validate_target("10.0.0.1:443", false).is_err());
assert!(validate_target("192.168.1.1", false).is_err());
assert!(validate_target("127.0.0.1", true).is_ok());
assert!(validate_target("10.0.0.1:443", true).is_ok());
assert!(validate_target("", true).is_err());
assert!(validate_target("example.com:99999", true).is_err());
}
#[test]
fn test_sanitize_path() {
let temp_dir = TempDir::new().expect("test assertion should succeed");
let base = temp_dir.path();
let test_file = base.join("test.txt");
fs::write(&test_file, "test").expect("test assertion should succeed");
let subdir = base.join("subdir");
fs::create_dir(&subdir).expect("test assertion should succeed");
let nested_file = subdir.join("test.txt");
fs::write(&nested_file, "test").expect("test assertion should succeed");
assert!(sanitize_path("test.txt", base).is_ok());
assert!(sanitize_path("subdir/test.txt", base).is_ok());
assert!(sanitize_path("../etc/passwd", base).is_err());
assert!(sanitize_path("../../etc/passwd", base).is_err());
assert!(sanitize_path("./../etc/passwd", base).is_err());
assert!(sanitize_path("/etc/passwd", base).is_err());
assert!(sanitize_path("test\0.txt", base).is_err());
}
}