spf: apply dns lookup limits

Noticed that we weren't apply the limits specified by
https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
This commit is contained in:
Wez Furlong
2025-10-05 09:50:45 +01:00
parent 2654ab373a
commit 38fceae7ea
3 changed files with 77 additions and 3 deletions
+41 -2
View File
@@ -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<AtomicUsize>,
}
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<Option<String>, 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) {
+13 -1
View File
@@ -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()),
+23
View File
@@ -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