kumo_spf/
lib.rs

1use crate::record::Record;
2use crate::spec::MacroSpec;
3use dns_resolver::{DnsError, Resolver};
4use hickory_resolver::proto::rr::RecordType;
5use hickory_resolver::Name;
6use instant_xml::{FromXml, ToXml};
7use serde::{Serialize, Serializer};
8use std::fmt;
9use std::net::IpAddr;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use std::time::SystemTime;
13
14pub mod record;
15mod spec;
16use record::Qualifier;
17#[cfg(test)]
18mod tests;
19
20#[derive(Debug, Clone, Copy, Eq, FromXml, PartialEq, ToXml)]
21#[xml(scalar, rename_all = "lowercase")]
22pub enum SpfDisposition {
23    /// A result of "none" means either (a) no syntactically valid DNS domain
24    /// name was extracted from the SMTP session that could be used as the
25    /// one to be authorized, or (b) no SPF records were retrieved from
26    /// the DNS.
27    None,
28
29    /// A "neutral" result means the ADMD has explicitly stated that it is
30    /// not asserting whether the IP address is authorized.
31    Neutral,
32
33    /// A "pass" result is an explicit statement that the client is
34    /// authorized to inject mail with the given identity.
35    Pass,
36
37    /// A "fail" result is an explicit statement that the client is not
38    /// authorized to use the domain in the given identity.
39    Fail,
40
41    /// A "softfail" result is a weak statement by the publishing ADMD that
42    /// the host is probably not authorized.  It has not published a
43    /// stronger, more definitive policy that results in a "fail".
44    SoftFail,
45
46    /// A "temperror" result means the SPF verifier encountered a transient
47    /// (generally DNS) error while performing the check.  A later retry may
48    /// succeed without further DNS operator action.
49    TempError,
50
51    /// A "permerror" result means the domain's published records could not
52    /// be correctly interpreted.  This signals an error condition that
53    /// definitely requires DNS operator intervention to be resolved.
54    PermError,
55}
56
57impl SpfDisposition {
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Self::None => "none",
61            Self::Neutral => "neutral",
62            Self::Pass => "pass",
63            Self::Fail => "fail",
64            Self::SoftFail => "softfail",
65            Self::TempError => "temperror",
66            Self::PermError => "permerror",
67        }
68    }
69}
70
71impl Serialize for SpfDisposition {
72    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73        serializer.serialize_str(self.as_str())
74    }
75}
76
77impl From<Qualifier> for SpfDisposition {
78    fn from(qualifier: Qualifier) -> Self {
79        match qualifier {
80            Qualifier::Pass => Self::Pass,
81            Qualifier::Fail => Self::Fail,
82            Qualifier::SoftFail => Self::SoftFail,
83            Qualifier::Neutral => Self::Neutral,
84        }
85    }
86}
87
88impl fmt::Display for SpfDisposition {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}", self.as_str())
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SpfResult {
96    pub disposition: SpfDisposition,
97    pub context: String,
98}
99
100impl SpfResult {
101    fn fail(context: String) -> Self {
102        Self {
103            disposition: SpfDisposition::Fail,
104            context,
105        }
106    }
107}
108
109pub struct CheckHostParams {
110    /// Domain that provides the sought-after authorization information.
111    ///
112    /// Initially, the domain portion of the "MAIL FROM" or "HELO" identity.
113    pub domain: String,
114
115    /// The "MAIL FROM" email address if available.
116    pub sender: Option<String>,
117
118    /// IP address of the SMTP client that is emitting the mail (v4 or v6).
119    pub client_ip: IpAddr,
120
121    /// Explicitly the domain name passed to HELO/EHLO,
122    /// regardless of the `domain` value.
123    pub ehlo_domain: Option<String>,
124
125    /// The host name of this host, the one doing the check
126    pub relaying_host_name: Option<String>,
127}
128
129impl CheckHostParams {
130    pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
131        let Self {
132            domain,
133            sender,
134            client_ip,
135            ehlo_domain,
136            relaying_host_name,
137        } = self;
138
139        let sender = match sender {
140            Some(sender) => sender,
141            None => format!("postmaster@{domain}"),
142        };
143
144        match SpfContext::new(&sender, &domain, client_ip) {
145            Ok(cx) => {
146                cx.with_ehlo_domain(ehlo_domain.as_deref())
147                    .with_relaying_host_name(relaying_host_name.as_deref())
148                    .check(resolver, true)
149                    .await
150            }
151            Err(result) => result,
152        }
153    }
154}
155
156struct SpfContext<'a> {
157    pub(crate) sender: &'a str,
158    pub(crate) local_part: &'a str,
159    pub(crate) sender_domain: &'a str,
160    pub(crate) domain: &'a str,
161    pub(crate) client_ip: IpAddr,
162    pub(crate) now: SystemTime,
163    pub(crate) ehlo_domain: Option<&'a str>,
164    pub(crate) relaying_host_name: &'a str,
165    lookups_remaining: Arc<AtomicUsize>,
166}
167
168impl<'a> SpfContext<'a> {
169    /// Create a new evaluation context.
170    ///
171    /// - `sender` is the "MAIL FROM" or "HELO" identity
172    /// - `domain` is the domain that provides the sought-after authorization information;
173    ///   initially, the domain portion of the "MAIL FROM" or "HELO" identity
174    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
175    fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
176        let Some((local_part, sender_domain)) = sender.split_once('@') else {
177            return Err(SpfResult {
178                disposition: SpfDisposition::PermError,
179                context:
180                    "input sender parameter '{sender}' is missing @ sign to delimit local part and domain".to_owned(),
181            });
182        };
183
184        Ok(Self {
185            sender,
186            local_part,
187            sender_domain,
188            domain,
189            client_ip,
190            now: SystemTime::now(),
191            ehlo_domain: None,
192            relaying_host_name: "localhost",
193            lookups_remaining: Arc::new(AtomicUsize::new(10)),
194        })
195    }
196
197    pub fn with_ehlo_domain(&self, ehlo_domain: Option<&'a str>) -> Self {
198        Self {
199            ehlo_domain,
200            lookups_remaining: self.lookups_remaining.clone(),
201            ..*self
202        }
203    }
204
205    pub fn with_relaying_host_name(&self, relaying_host_name: Option<&'a str>) -> Self {
206        Self {
207            relaying_host_name: relaying_host_name.unwrap_or(self.relaying_host_name),
208            lookups_remaining: self.lookups_remaining.clone(),
209            ..*self
210        }
211    }
212
213    pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
214        Self {
215            domain,
216            lookups_remaining: self.lookups_remaining.clone(),
217            ..*self
218        }
219    }
220
221    pub(crate) fn check_lookup_limit(&self) -> Result<(), SpfResult> {
222        let remain = self.lookups_remaining.load(Ordering::Relaxed);
223        if remain > 0 {
224            self.lookups_remaining.store(remain - 1, Ordering::Relaxed);
225            return Ok(());
226        }
227
228        Err(SpfResult {
229            disposition: SpfDisposition::PermError,
230            context: "DNS lookup limits exceeded".to_string(),
231        })
232    }
233
234    pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
235        if !initial {
236            if let Err(err) = self.check_lookup_limit() {
237                return err;
238            }
239        }
240
241        let name = match Name::from_str_relaxed(self.domain) {
242            Ok(name) => name,
243            Err(_) => {
244                // Per <https://www.rfc-editor.org/rfc/rfc7208#section-4.3>, invalid
245                // domain names yield a "none" result during initial processing.
246                let context = format!("invalid domain name: {}", self.domain);
247                return if initial {
248                    SpfResult {
249                        disposition: SpfDisposition::None,
250                        context,
251                    }
252                } else {
253                    SpfResult {
254                        disposition: SpfDisposition::TempError,
255                        context,
256                    }
257                };
258            }
259        };
260
261        let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
262            Ok(answer) => {
263                if answer.records.is_empty() || answer.nxdomain {
264                    return SpfResult {
265                        disposition: SpfDisposition::None,
266                        context: if answer.records.is_empty() {
267                            format!("no SPF records found for {}", &self.domain)
268                        } else {
269                            format!("domain {} not found", &self.domain)
270                        },
271                    };
272                } else {
273                    answer.as_txt()
274                }
275            }
276            Err(err) => {
277                return SpfResult {
278                    disposition: match err {
279                        DnsError::InvalidName(_) => SpfDisposition::PermError,
280                        DnsError::ResolveFailed(_) => SpfDisposition::TempError,
281                    },
282                    context: format!("{err}"),
283                };
284            }
285        };
286
287        // TXT records can contain all sorts of stuff, let's walk through
288        // the set that we retrieved and take the first one that parses
289        for txt in initial_txt {
290            // a little bit of a layering violation: we need to know
291            // whether we had an SPF record candidate or not to be
292            // able to return an appropriate disposition if they have
293            // TXT records but no SPF records.
294            if txt.starts_with("v=spf1 ") {
295                match Record::parse(&txt) {
296                    Ok(record) => return record.evaluate(self, resolver).await,
297                    Err(err) => {
298                        return SpfResult {
299                            disposition: SpfDisposition::PermError,
300                            context: format!("failed to parse spf record: {err}"),
301                        };
302                    }
303                }
304            }
305        }
306        SpfResult {
307            disposition: SpfDisposition::None,
308            context: format!("no SPF records found for {}", &self.domain),
309        }
310    }
311
312    pub(crate) async fn domain(
313        &self,
314        spec: Option<&MacroSpec>,
315        resolver: &dyn Resolver,
316    ) -> Result<String, SpfResult> {
317        let Some(spec) = spec else {
318            return Ok(self.domain.to_owned());
319        };
320
321        spec.expand(self, resolver).await.map_err(|err| SpfResult {
322            disposition: SpfDisposition::TempError,
323            context: format!("error evaluating domain spec: {err}"),
324        })
325    }
326
327    pub(crate) async fn validated_domain(
328        &self,
329        spec: Option<&MacroSpec>,
330        resolver: &dyn Resolver,
331    ) -> Result<Option<String>, SpfResult> {
332        // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
333        self.check_lookup_limit()?;
334
335        let domain = self.domain(spec, resolver).await?;
336
337        let domain = match Name::from_str_relaxed(&domain) {
338            Ok(domain) => domain,
339            Err(err) => {
340                return Err(SpfResult {
341                    disposition: SpfDisposition::PermError,
342                    context: format!("error parsing domain name: {err}"),
343                })
344            }
345        };
346
347        let ptrs = match resolver.resolve_ptr(self.client_ip).await {
348            Ok(ptrs) => ptrs,
349            Err(err) => {
350                return Err(SpfResult {
351                    disposition: SpfDisposition::TempError,
352                    context: format!("error looking up PTR for {}: {err}", self.client_ip),
353                })
354            }
355        };
356
357        for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
358            if idx >= 10 {
359                // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
360                return Err(SpfResult {
361                    disposition: SpfDisposition::PermError,
362                    context: format!("too many PTR records for {}", self.client_ip),
363                });
364            }
365            match resolver.resolve_ip(&ptr.to_string()).await {
366                Ok(ips) => {
367                    if ips.iter().any(|&ip| ip == self.client_ip) {
368                        let mut ptr = ptr.clone();
369                        // Remove trailing dot
370                        ptr.set_fqdn(false);
371                        return Ok(Some(ptr.to_string()));
372                    }
373                }
374                Err(err) => {
375                    return Err(SpfResult {
376                        disposition: SpfDisposition::TempError,
377                        context: format!("error looking up IP for {ptr}: {err}"),
378                    })
379                }
380            }
381        }
382
383        Ok(None)
384    }
385}