mirror of
https://github.com/mailscope/kumomta.git
synced 2026-10-30 01:56:52 +00:00
spf: flesh out SPF some more
This commit is contained in:
Generated
+3
@@ -3116,7 +3116,10 @@ name = "kumo-spf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cidr",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"k9 0.11.6",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user