From 575a4f6fd5711e5f2ccd4e6a469024fa7adc701a Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Thu, 3 Oct 2024 12:05:48 +0200 Subject: [PATCH] spf: flesh out SPF some more --- Cargo.lock | 3 ++ crates/kumo-spf/Cargo.toml | 3 ++ crates/kumo-spf/src/dns.rs | 34 ++++++++++++++++++++ crates/kumo-spf/src/error.rs | 9 ++++++ crates/kumo-spf/src/eval.rs | 31 ++++++++++++++++-- crates/kumo-spf/src/lib.rs | 60 +++++++++++++++++++++++++++++++++++ crates/kumo-spf/src/record.rs | 49 ++++++++++++++++++---------- 7 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 crates/kumo-spf/src/dns.rs create mode 100644 crates/kumo-spf/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index eda6a30d..d0660a72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3116,7 +3116,10 @@ name = "kumo-spf" version = "0.1.0" dependencies = [ "cidr", + "futures", + "hickory-resolver", "k9 0.11.6", + "thiserror", ] [[package]] diff --git a/crates/kumo-spf/Cargo.toml b/crates/kumo-spf/Cargo.toml index 2b568ab5..ad14bc41 100644 --- a/crates/kumo-spf/Cargo.toml +++ b/crates/kumo-spf/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" [dependencies] cidr = "0.2" +futures = {workspace=true} +hickory-resolver = {workspace=true} +thiserror = "1.0" [dev-dependencies] k9 = "0.11" diff --git a/crates/kumo-spf/src/dns.rs b/crates/kumo-spf/src/dns.rs new file mode 100644 index 00000000..cca5115f --- /dev/null +++ b/crates/kumo-spf/src/dns.rs @@ -0,0 +1,34 @@ +use crate::error::SpfError; +use futures::future::BoxFuture; +use hickory_resolver::error::ResolveErrorKind; +use hickory_resolver::TokioAsyncResolver; + +/// A trait for entities that perform DNS resolution. +pub trait Lookup: Sync + Send { + fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>>; +} + +impl Lookup for TokioAsyncResolver { + fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>> { + Box::pin(async move { + self.txt_lookup(name) + .await + .map_err(|err| match err.kind() { + ResolveErrorKind::NoRecordsFound { .. } => { + SpfError::DnsRecordNotFound(name.to_string()) + } + _ => { + SpfError::DnsLookupFailed(format!("failed to query DNS for {name}: {err}")) + } + })? + .into_iter() + .map(|txt| { + Ok(txt + .iter() + .map(|data| String::from_utf8_lossy(data)) + .collect()) + }) + .collect() + }) + } +} diff --git a/crates/kumo-spf/src/error.rs b/crates/kumo-spf/src/error.rs new file mode 100644 index 00000000..d5aa462b --- /dev/null +++ b/crates/kumo-spf/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SpfError { + #[error("DNS record {0} not found")] + DnsRecordNotFound(String), + #[error("{0}")] + DnsLookupFailed(String), +} diff --git a/crates/kumo-spf/src/eval.rs b/crates/kumo-spf/src/eval.rs index 89b09deb..e73b4c03 100644 --- a/crates/kumo-spf/src/eval.rs +++ b/crates/kumo-spf/src/eval.rs @@ -105,7 +105,34 @@ impl EvalContext { } } - result.push_str(&tokens.join(".")); + let output = tokens.join("."); + + if m.url_escape { + // https://datatracker.ietf.org/doc/html/rfc7208#section-7.3: + // Uppercase macros expand exactly as their lowercase + // equivalents, and are then URL escaped. URL escaping + // MUST be performed for characters not in the + // "unreserved" set. + // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3: + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + for c in output.chars() { + if c.is_ascii_alphanumeric() + || c == '-' + || c == '.' + || c == '_' + || c == '~' + { + result.push(c); + } else { + let mut bytes = [0u8; 4]; + for b in c.encode_utf8(&mut bytes).bytes() { + result.push_str(&format!("%{b:02x}")); + } + } + } + } else { + result.push_str(&output); + } } } } @@ -183,7 +210,7 @@ mod test { .0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com", ), ("%{c}", "2001:db8::cb01"), - ("%{C}", "2001:db8::cb01"), + ("%{C}", "2001%3adb8%3a%3acb01"), ] { let spec = DomainSpec::parse(input).unwrap(); let output = ctx.evaluate(&spec.elements).unwrap(); diff --git a/crates/kumo-spf/src/lib.rs b/crates/kumo-spf/src/lib.rs index 6045448c..f65ae8f1 100644 --- a/crates/kumo-spf/src/lib.rs +++ b/crates/kumo-spf/src/lib.rs @@ -1,2 +1,62 @@ +use std::net::IpAddr; + +pub mod dns; +pub mod error; pub mod eval; pub mod record; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpfDisposition { + /// A result of "none" means either (a) no syntactically valid DNS domain + /// name was extracted from the SMTP session that could be used as the + /// one to be authorized, or (b) no SPF records were retrieved from + /// the DNS. + None, + + /// A "neutral" result means the ADMD has explicitly stated that it is + /// not asserting whether the IP address is authorized. + Neutral, + + /// A "pass" result is an explicit statement that the client is + /// authorized to inject mail with the given identity. + Pass, + + /// A "fail" result is an explicit statement that the client is not + /// authorized to use the domain in the given identity. + Fail, + + /// A "softfail" result is a weak statement by the publishing ADMD that + /// the host is probably not authorized. It has not published a + /// stronger, more definitive policy that results in a "fail". + SoftFail, + + /// A "temperror" result means the SPF verifier encountered a transient + /// (generally DNS) error while performing the check. A later retry may + /// succeed without further DNS operator action. + TempError, + + /// A "permerror" result means the domain's published records could not + /// be correctly interpreted. This signals an error condition that + /// definitely requires DNS operator intervention to be resolved. + PermError, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpfResult { + pub disposition: SpfDisposition, + pub context: String, +} + +pub struct CheckHostParams { + /// the IP address of the SMTP client that is emitting the mail, + /// either IPv4 or IPv6. + pub client_ip: IpAddr, + + /// the domain that provides the sought-after authorization + /// information; initially, the domain portion of the + /// "MAIL FROM" or "HELO" identity. + pub domain: String, + + /// the "MAIL FROM" or "HELO" identity. + pub sender: String, +} diff --git a/crates/kumo-spf/src/record.rs b/crates/kumo-spf/src/record.rs index 18d1d0f1..0710598a 100644 --- a/crates/kumo-spf/src/record.rs +++ b/crates/kumo-spf/src/record.rs @@ -365,7 +365,15 @@ impl DomainSpec { continue; } if s.starts_with("%{") { - let name = MacroName::parse(&s[2..3])?; + if s.len() < 4 { + return Err(format!("unexpected end of input in {s}")); + } + + let (name, url_escape) = MacroName::parse( + s.chars() + .nth(2) + .ok_or_else(|| format!("unexpected end of input in {s}"))?, + )?; let mut transformer_digits = None; let mut reverse = false; @@ -391,6 +399,7 @@ impl DomainSpec { name, transformer_digits, reverse, + url_escape, delimiters: delimiters.to_string(), })); @@ -421,6 +430,8 @@ pub struct MacroTerm { pub name: MacroName, /// digits were present in the transformer section pub transformer_digits: Option, + /// The output needs to be URL-escaped + pub url_escape: bool, /// the `r` transformer was present pub reverse: bool, /// The list of delimiters, if any, otherwise an empty string @@ -454,21 +465,25 @@ pub enum MacroName { } impl MacroName { - fn parse(s: &str) -> Result { - Ok(match s { - "s" | "S" => Self::Sender, - "l" | "L" => Self::LocalPart, - "o" | "O" => Self::SenderDomain, - "d" | "D" => Self::Domain, - "i" | "I" => Self::Ip, - "p" | "P" => Self::ValidatedDomainName, - "v" | "V" => Self::ReverseDns, - "h" | "H" => Self::HeloDomain, - "c" | "C" => Self::ClientIp, - "r" | "R" => Self::RelayingHostName, - "t" | "T" => Self::CurrentUnixTimeStamp, - _ => return Err(format!("invalid macro name {s}")), - }) + fn parse(c: char) -> Result<(Self, bool), String> { + let escape = c.is_ascii_uppercase(); + Ok(( + match c.to_ascii_lowercase() { + 's' => Self::Sender, + 'l' => Self::LocalPart, + 'o' => Self::SenderDomain, + 'd' => Self::Domain, + 'i' => Self::Ip, + 'p' => Self::ValidatedDomainName, + 'v' => Self::ReverseDns, + 'h' => Self::HeloDomain, + 'c' => Self::ClientIp, + 'r' => Self::RelayingHostName, + 't' => Self::CurrentUnixTimeStamp, + _ => return Err(format!("invalid macro name {c}")), + }, + escape, + )) } } @@ -533,6 +548,7 @@ Record { MacroTerm { name: Domain, transformer_digits: None, + url_escape: false, reverse: false, delimiters: "", }, @@ -561,6 +577,7 @@ Record { MacroTerm { name: Ip, transformer_digits: None, + url_escape: false, reverse: true, delimiters: "", },