diff --git a/crates/kumo-spf/src/dns.rs b/crates/kumo-spf/src/dns.rs index 0cc0904e..7ff3d74b 100644 --- a/crates/kumo-spf/src/dns.rs +++ b/crates/kumo-spf/src/dns.rs @@ -1,11 +1,12 @@ use crate::error::SpfError; use futures::future::BoxFuture; -use hickory_resolver::TokioAsyncResolver; +use hickory_resolver::{Name, TokioAsyncResolver}; use std::net::IpAddr; /// A trait for entities that perform DNS resolution. pub trait Lookup: Sync + Send { fn lookup_ip<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>>; + fn lookup_mx<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>>; fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>>; } @@ -21,6 +22,17 @@ impl Lookup for TokioAsyncResolver { }) } + fn lookup_mx<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>> { + Box::pin(async move { + self.mx_lookup(name) + .await + .map_err(|err| SpfError::from_resolve(name, err))? + .into_iter() + .map(|mx| Ok(mx.exchange().clone())) + .collect() + }) + } + fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, SpfError>> { Box::pin(async move { self.txt_lookup(name) diff --git a/crates/kumo-spf/src/record.rs b/crates/kumo-spf/src/record.rs index ea28c386..4610e4d2 100644 --- a/crates/kumo-spf/src/record.rs +++ b/crates/kumo-spf/src/record.rs @@ -106,6 +106,41 @@ impl Directive { .iter() .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip)) } + Mechanism::Mx { domain, cidr_len } => { + let domain = cx.domain(domain)?; + let exchanges = match resolver.lookup_mx(&domain).await { + Ok(exchanges) => exchanges, + Err(err) => { + return Err(SpfResult { + disposition: SpfDisposition::TempError, + context: format!("error looking up IP for {domain}: {err}"), + }) + } + }; + + let mut matched = false; + for exchange in exchanges { + let resolved = match resolver.lookup_ip(&exchange.to_string()).await { + Ok(ips) => ips, + Err(err) => { + return Err(SpfResult { + disposition: SpfDisposition::TempError, + context: format!("error looking up IP for {exchange}: {err}"), + }) + } + }; + + if resolved + .iter() + .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip)) + { + matched = true; + break; + } + } + + matched + } _ => todo!("evaluate directive {self:?}"), }; diff --git a/crates/kumo-spf/src/tests.rs b/crates/kumo-spf/src/tests.rs index 5b15704e..01fb614b 100644 --- a/crates/kumo-spf/src/tests.rs +++ b/crates/kumo-spf/src/tests.rs @@ -2,7 +2,7 @@ use crate::dns::Lookup; use crate::error::SpfError; use crate::{CheckHostParams, SpfDisposition, SpfResult}; use futures::future::BoxFuture; -use hickory_proto::rr::rdata::{A, AAAA, TXT}; +use hickory_proto::rr::rdata::{A, AAAA, MX, TXT}; use hickory_proto::rr::{LowerName, RData, RecordData, RecordSet, RecordType, RrKey}; use hickory_proto::serialize::txt::Parser; use hickory_resolver::Name; @@ -99,6 +99,95 @@ async fn ip() { ); } +/// https://www.rfc-editor.org/rfc/rfc7208#appendix-A.1 +#[tokio::test] +async fn mx() { + let resolver = TestResolver::default() + .with_zone(EXAMPLE_COM) + .with_spf("example.com", "v=spf1 mx -all".to_string()); + + let result = CheckHostParams { + client_ip: IpAddr::V4(Ipv4Addr::from([192, 0, 2, 129])), + domain: "example.com".to_string(), + sender: "sender@example.com".to_string(), + } + .run(&resolver) + .await; + + k9::assert_equal!( + &result, + &SpfResult { + disposition: SpfDisposition::Pass, + context: "matched 'mx' directive".to_owned(), + }, + "{result:?}" + ); + + let resolver = TestResolver::default() + .with_zone(EXAMPLE_COM) + .with_zone(EXAMPLE_ORG) + .with_spf("example.com", "v=spf1 mx:example.org -all".to_string()); + + let result = CheckHostParams { + client_ip: IpAddr::V4(Ipv4Addr::from([192, 0, 2, 140])), + domain: "example.com".to_string(), + sender: "sender@example.com".to_string(), + } + .run(&resolver) + .await; + + k9::assert_equal!( + &result, + &SpfResult { + disposition: SpfDisposition::Pass, + context: "matched 'mx:example.org' directive".to_owned(), + }, + "{result:?}" + ); + + let resolver = TestResolver::default() + .with_zone(EXAMPLE_COM) + .with_zone(EXAMPLE_ORG) + .with_spf( + "example.com", + "v=spf1 mx/30 mx:example.org/30 -all".to_string(), + ); + + let result = CheckHostParams { + client_ip: IpAddr::V4(Ipv4Addr::from([192, 0, 2, 131])), + domain: "example.com".to_string(), + sender: "sender@example.com".to_string(), + } + .run(&resolver) + .await; + + k9::assert_equal!( + &result, + &SpfResult { + disposition: SpfDisposition::Pass, + context: "matched 'mx/30' directive".to_owned(), + }, + "{result:?}" + ); + + let result = CheckHostParams { + client_ip: IpAddr::V4(Ipv4Addr::from([192, 0, 2, 141])), + domain: "example.com".to_string(), + sender: "sender@example.com".to_string(), + } + .run(&resolver) + .await; + + k9::assert_equal!( + &result, + &SpfResult { + disposition: SpfDisposition::Pass, + context: "matched 'mx:example.org/30' directive".to_owned(), + }, + "{result:?}" + ); +} + /// https://www.rfc-editor.org/rfc/rfc7208#appendix-A const EXAMPLE_COM: &str = r#"; A domain with two mail servers, two hosts, and two servers ; at the domain name @@ -206,6 +295,26 @@ impl Lookup for TestResolver { }) } + fn lookup_mx<'a>(&'a self, full: &'a str) -> BoxFuture<'a, Result, SpfError>> { + Box::pin(async move { + let records = match self.get(full, RecordType::MX)? { + Some(records) => records, + None => { + println!("key not found: {full}"); + return Err(SpfError::DnsRecordNotFound(full.to_string())); + } + }; + + let mut values = vec![]; + for record in records.records_without_rrsigs() { + let mx = MX::try_borrow(record.data().unwrap()).unwrap(); + values.push(mx.exchange().clone()); + } + + Ok(values) + }) + } + fn lookup_txt<'a>(&'a self, full: &'a str) -> BoxFuture<'a, Result, SpfError>> { Box::pin(async move { let records = match self.get(full, RecordType::TXT)? {