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(mut name) => {
243                name.set_fqdn(true);
244                name
245            }
246            Err(_) => {
247                // Per <https://www.rfc-editor.org/rfc/rfc7208#section-4.3>, invalid
248                // domain names yield a "none" result during initial processing.
249                let context = format!("invalid domain name: {}", self.domain);
250                return if initial {
251                    SpfResult {
252                        disposition: SpfDisposition::None,
253                        context,
254                    }
255                } else {
256                    SpfResult {
257                        disposition: SpfDisposition::TempError,
258                        context,
259                    }
260                };
261            }
262        };
263
264        let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
265            Ok(answer) => {
266                if answer.records.is_empty() || answer.nxdomain {
267                    return SpfResult {
268                        disposition: SpfDisposition::None,
269                        context: if answer.records.is_empty() {
270                            format!("no SPF records found for {}", &self.domain)
271                        } else {
272                            format!("domain {} not found", &self.domain)
273                        },
274                    };
275                } else {
276                    answer.as_txt()
277                }
278            }
279            Err(err) => {
280                return SpfResult {
281                    disposition: match err {
282                        DnsError::InvalidName(_) => SpfDisposition::PermError,
283                        DnsError::ResolveFailed(_) => SpfDisposition::TempError,
284                    },
285                    context: format!("{err}"),
286                };
287            }
288        };
289
290        // TXT records can contain all sorts of stuff, let's walk through
291        // the set that we retrieved and take the first one that parses
292        for txt in initial_txt {
293            // a little bit of a layering violation: we need to know
294            // whether we had an SPF record candidate or not to be
295            // able to return an appropriate disposition if they have
296            // TXT records but no SPF records.
297            if txt.starts_with("v=spf1 ") {
298                match Record::parse(&txt) {
299                    Ok(record) => return record.evaluate(self, resolver).await,
300                    Err(err) => {
301                        return SpfResult {
302                            disposition: SpfDisposition::PermError,
303                            context: format!("failed to parse spf record: {err}"),
304                        };
305                    }
306                }
307            }
308        }
309        SpfResult {
310            disposition: SpfDisposition::None,
311            context: format!("no SPF records found for {}", &self.domain),
312        }
313    }
314
315    pub(crate) async fn domain(
316        &self,
317        spec: Option<&MacroSpec>,
318        resolver: &dyn Resolver,
319    ) -> Result<String, SpfResult> {
320        let Some(spec) = spec else {
321            return Ok(self.domain.to_owned());
322        };
323
324        spec.expand(self, resolver).await.map_err(|err| SpfResult {
325            disposition: SpfDisposition::TempError,
326            context: format!("error evaluating domain spec: {err}"),
327        })
328    }
329
330    pub(crate) async fn validated_domain(
331        &self,
332        spec: Option<&MacroSpec>,
333        resolver: &dyn Resolver,
334    ) -> Result<Option<String>, SpfResult> {
335        // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
336        self.check_lookup_limit()?;
337
338        let domain = self.domain(spec, resolver).await?;
339
340        let domain = match Name::from_str_relaxed(&domain) {
341            Ok(domain) => domain,
342            Err(err) => {
343                return Err(SpfResult {
344                    disposition: SpfDisposition::PermError,
345                    context: format!("error parsing domain name: {err}"),
346                })
347            }
348        };
349
350        let ptrs = match resolver.resolve_ptr(self.client_ip).await {
351            Ok(ptrs) => ptrs,
352            Err(err) => {
353                return Err(SpfResult {
354                    disposition: SpfDisposition::TempError,
355                    context: format!("error looking up PTR for {}: {err}", self.client_ip),
356                })
357            }
358        };
359
360        for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
361            if idx >= 10 {
362                // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
363                return Err(SpfResult {
364                    disposition: SpfDisposition::PermError,
365                    context: format!("too many PTR records for {}", self.client_ip),
366                });
367            }
368            match resolver.resolve_ip(&fully_qualify(&ptr.to_string())).await {
369                Ok(ips) => {
370                    if ips.iter().any(|&ip| ip == self.client_ip) {
371                        let mut ptr = ptr.clone();
372                        // Remove trailing dot
373                        ptr.set_fqdn(false);
374                        return Ok(Some(ptr.to_string()));
375                    }
376                }
377                Err(err) => {
378                    return Err(SpfResult {
379                        disposition: SpfDisposition::TempError,
380                        context: format!("error looking up IP for {ptr}: {err}"),
381                    })
382                }
383            }
384        }
385
386        Ok(None)
387    }
388}
389
390pub(crate) fn fully_qualify(domain_name: &str) -> String {
391    match dns_resolver::fully_qualify(domain_name) {
392        Ok(name) => name.to_string(),
393        Err(_) => domain_name.to_string(),
394    }
395}