spf: flesh out SPF some more

This commit is contained in:
Wez Furlong
2024-10-12 08:00:15 -07:00
committed by Wez Furlong
parent 6d33b10a23
commit 575a4f6fd5
7 changed files with 171 additions and 18 deletions
Generated
+3
View File
@@ -3116,7 +3116,10 @@ name = "kumo-spf"
version = "0.1.0"
dependencies = [
"cidr",
"futures",
"hickory-resolver",
"k9 0.11.6",
"thiserror",
]
[[package]]
+3
View File
@@ -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"
+34
View File
@@ -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<Vec<String>, SpfError>>;
}
impl Lookup for TokioAsyncResolver {
fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<String>, 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()
})
}
}
+9
View File
@@ -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),
}
+29 -2
View File
@@ -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();
+60
View File
@@ -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,
}
+33 -16
View File
@@ -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<u32>,
/// 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<Self, String> {
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: "",
},