diff --git a/crates/kumo-spf/src/lib.rs b/crates/kumo-spf/src/lib.rs index 32c2509c..029f83bf 100644 --- a/crates/kumo-spf/src/lib.rs +++ b/crates/kumo-spf/src/lib.rs @@ -7,6 +7,8 @@ use instant_xml::{FromXml, ToXml}; use serde::{Serialize, Serializer}; use std::fmt; use std::net::IpAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::SystemTime; pub mod record; @@ -160,6 +162,7 @@ struct SpfContext<'a> { pub(crate) now: SystemTime, pub(crate) ehlo_domain: Option<&'a str>, pub(crate) relaying_host_name: &'a str, + lookups_remaining: Arc, } impl<'a> SpfContext<'a> { @@ -187,12 +190,14 @@ impl<'a> SpfContext<'a> { now: SystemTime::now(), ehlo_domain: None, relaying_host_name: "localhost", + lookups_remaining: Arc::new(AtomicUsize::new(10)), }) } pub fn with_ehlo_domain(&self, ehlo_domain: Option<&'a str>) -> Self { Self { ehlo_domain, + lookups_remaining: self.lookups_remaining.clone(), ..*self } } @@ -200,15 +205,39 @@ impl<'a> SpfContext<'a> { pub fn with_relaying_host_name(&self, relaying_host_name: Option<&'a str>) -> Self { Self { relaying_host_name: relaying_host_name.unwrap_or(self.relaying_host_name), + lookups_remaining: self.lookups_remaining.clone(), ..*self } } pub(crate) fn with_domain(&self, domain: &'a str) -> Self { - Self { domain, ..*self } + Self { + domain, + lookups_remaining: self.lookups_remaining.clone(), + ..*self + } + } + + pub(crate) fn check_lookup_limit(&self) -> Result<(), SpfResult> { + let remain = self.lookups_remaining.load(Ordering::Relaxed); + if remain > 0 { + self.lookups_remaining.store(remain - 1, Ordering::Relaxed); + return Ok(()); + } + + Err(SpfResult { + disposition: SpfDisposition::PermError, + context: "DNS lookup limits exceeded".to_string(), + }) } pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult { + if !initial { + if let Err(err) = self.check_lookup_limit() { + return err; + } + } + let name = match Name::from_str_relaxed(self.domain) { Ok(name) => name, Err(_) => { @@ -300,6 +329,9 @@ impl<'a> SpfContext<'a> { spec: Option<&MacroSpec>, resolver: &dyn Resolver, ) -> Result, SpfResult> { + // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4 + self.check_lookup_limit()?; + let domain = self.domain(spec, resolver).await?; let domain = match Name::from_str_relaxed(&domain) { @@ -322,7 +354,14 @@ impl<'a> SpfContext<'a> { } }; - for ptr in ptrs.iter().filter(|ptr| domain.zone_of(ptr)) { + for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() { + if idx >= 10 { + // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4 + return Err(SpfResult { + disposition: SpfDisposition::PermError, + context: format!("too many PTR records for {}", self.client_ip), + }); + } match resolver.resolve_ip(&ptr.to_string()).await { Ok(ips) => { if ips.iter().any(|&ip| ip == self.client_ip) { diff --git a/crates/kumo-spf/src/record.rs b/crates/kumo-spf/src/record.rs index 4c2f92cf..4f43b335 100644 --- a/crates/kumo-spf/src/record.rs +++ b/crates/kumo-spf/src/record.rs @@ -160,6 +160,7 @@ impl Directive { let matched = match &self.mechanism { Mechanism::All => true, Mechanism::A { domain, cidr_len } => { + cx.check_lookup_limit()?; let domain = cx.domain(domain.as_ref(), resolver).await?; let resolved = match resolver.resolve_ip(&domain).await { Ok(ips) => ips, @@ -176,6 +177,7 @@ impl Directive { .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip)) } Mechanism::Mx { domain, cidr_len } => { + cx.check_lookup_limit()?; let domain = cx.domain(domain.as_ref(), resolver).await?; let exchanges = match resolver.resolve_mx(&domain).await { Ok(exchanges) => exchanges, @@ -188,7 +190,15 @@ impl Directive { }; let mut matched = false; - for exchange in exchanges { + for (idx, exchange) in exchanges.into_iter().enumerate() { + if idx >= 10 { + // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4 + return Err(SpfResult { + disposition: SpfDisposition::PermError, + context: format!("too many MX records for {domain}"), + }); + } + let resolved = match resolver.resolve_ip(&exchange.to_string()).await { Ok(ips) => ips, Err(err) => { @@ -227,6 +237,7 @@ impl Directive { } .matches(cx.client_ip, IpAddr::V6(*ip6_network)), Mechanism::Ptr { domain } => { + // Note that validated_domain applies lookup limits let validated_domain = cx.validated_domain(domain.as_ref(), resolver).await?; validated_domain.is_some() @@ -266,6 +277,7 @@ impl Directive { } } Mechanism::Exists { domain } => { + cx.check_lookup_limit()?; let domain = cx.domain(Some(domain), resolver).await?; match resolver.resolve_ip(&domain).await { Ok(ips) => ips.iter().any(|ip| ip.is_ipv4()), diff --git a/crates/kumo-spf/src/tests.rs b/crates/kumo-spf/src/tests.rs index 1d57c7ba..a73cbb41 100644 --- a/crates/kumo-spf/src/tests.rs +++ b/crates/kumo-spf/src/tests.rs @@ -395,6 +395,29 @@ async fn ptr() { ); } +#[tokio::test] +async fn lookup_limits() { + let mut resolver = TestResolver::default().with_zone(EXAMPLE_COM); + for i in 1..=15 { + resolver = resolver.with_txt( + &format!("inc{i}.com"), + format!("v=spf1 redirect=inc{}.com", i + 1), + ); + } + + let resolver = resolver.with_txt("example.com", "v=spf1 redirect=inc1.com".to_string()); + + let result = evaluate_ip(Ipv4Addr::from([192, 0, 2, 65]), &resolver).await; + k9::assert_equal!( + &result, + &SpfResult { + disposition: SpfDisposition::PermError, + context: "DNS lookup limits exceeded".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