kumo_spf/
lib.rs

1use crate::record::Record;
2use crate::spec::MacroSpec;
3use dns_resolver::{DnsError, Resolver};
4use hickory_resolver::proto::rr::{Name, RecordType};
5use instant_xml::{FromXml, ToXml};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::net::IpAddr;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::Arc;
11use std::time::SystemTime;
12
13pub mod record;
14mod spec;
15use record::Qualifier;
16#[cfg(test)]
17mod tests;
18
19#[derive(Debug, Clone, Copy, Eq, FromXml, PartialEq, ToXml, Serialize, Deserialize)]
20#[xml(scalar, rename_all = "lowercase")]
21#[serde(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 From<String> for SpfDisposition {
72    fn from(value: String) -> Self {
73        match value.to_lowercase().as_str() {
74            "none" => Self::None,
75            "neutral" => Self::Neutral,
76            "pass" => Self::Pass,
77            "fail" => Self::Fail,
78            "softfail" => Self::SoftFail,
79            "temperror" => Self::TempError,
80            "permerror" => Self::PermError,
81            _ => Self::None,
82        }
83    }
84}
85
86impl From<Qualifier> for SpfDisposition {
87    fn from(qualifier: Qualifier) -> Self {
88        match qualifier {
89            Qualifier::Pass => Self::Pass,
90            Qualifier::Fail => Self::Fail,
91            Qualifier::SoftFail => Self::SoftFail,
92            Qualifier::Neutral => Self::Neutral,
93        }
94    }
95}
96
97impl fmt::Display for SpfDisposition {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}", self.as_str())
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SpfResult {
105    pub disposition: SpfDisposition,
106    pub context: String,
107}
108
109impl SpfResult {
110    fn fail(context: String) -> Self {
111        Self {
112            disposition: SpfDisposition::Fail,
113            context,
114        }
115    }
116}
117
118pub struct CheckHostParams {
119    /// Domain that provides the sought-after authorization information.
120    ///
121    /// Initially, the domain portion of the "MAIL FROM" or "HELO" identity.
122    pub domain: String,
123
124    /// The "MAIL FROM" email address if available.
125    pub sender: Option<String>,
126
127    /// IP address of the SMTP client that is emitting the mail (v4 or v6).
128    pub client_ip: IpAddr,
129
130    /// Explicitly the domain name passed to HELO/EHLO,
131    /// regardless of the `domain` value.
132    pub ehlo_domain: Option<String>,
133
134    /// The host name of this host, the one doing the check
135    pub relaying_host_name: Option<String>,
136}
137
138impl CheckHostParams {
139    pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
140        let Self {
141            mut domain,
142            sender,
143            client_ip,
144            ehlo_domain,
145            relaying_host_name,
146        } = self;
147
148        // When MAIL FROM is blank and domain is empty, fallback to EHLO domain
149        if domain.is_empty() {
150            if let Some(ehlo) = &ehlo_domain {
151                domain = ehlo.clone();
152            }
153        }
154
155        let sender = match sender {
156            Some(sender) => sender,
157            None => format!("postmaster@{domain}"),
158        };
159
160        match SpfContext::new(&sender, &domain, client_ip) {
161            Ok(cx) => {
162                cx.with_ehlo_domain(ehlo_domain.as_deref())
163                    .with_relaying_host_name(relaying_host_name.as_deref())
164                    .check(resolver, true)
165                    .await
166            }
167            Err(result) => result,
168        }
169    }
170}
171
172struct SpfContext<'a> {
173    pub(crate) sender: &'a str,
174    pub(crate) local_part: &'a str,
175    pub(crate) sender_domain: &'a str,
176    pub(crate) domain: &'a str,
177    pub(crate) client_ip: IpAddr,
178    pub(crate) now: SystemTime,
179    pub(crate) ehlo_domain: Option<&'a str>,
180    pub(crate) relaying_host_name: &'a str,
181    lookups_remaining: Arc<AtomicUsize>,
182}
183
184impl<'a> SpfContext<'a> {
185    /// Create a new evaluation context.
186    ///
187    /// - `sender` is the "MAIL FROM" or "HELO" identity
188    /// - `domain` is the domain that provides the sought-after authorization information;
189    ///   initially, the domain portion of the "MAIL FROM" or "HELO" identity
190    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
191    fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
192        let (local_part, sender_domain) = match sender.rsplit_once('@') {
193            Some((local_part, sender_domain)) => (local_part, sender_domain),
194            None => ("postmaster", sender),
195        };
196
197        Ok(Self {
198            sender,
199            local_part,
200            sender_domain,
201            domain,
202            client_ip,
203            now: SystemTime::now(),
204            ehlo_domain: None,
205            relaying_host_name: "localhost",
206            lookups_remaining: Arc::new(AtomicUsize::new(10)),
207        })
208    }
209
210    pub fn with_ehlo_domain(&self, ehlo_domain: Option<&'a str>) -> Self {
211        Self {
212            ehlo_domain,
213            lookups_remaining: self.lookups_remaining.clone(),
214            ..*self
215        }
216    }
217
218    pub fn with_relaying_host_name(&self, relaying_host_name: Option<&'a str>) -> Self {
219        Self {
220            relaying_host_name: relaying_host_name.unwrap_or(self.relaying_host_name),
221            lookups_remaining: self.lookups_remaining.clone(),
222            ..*self
223        }
224    }
225
226    pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
227        Self {
228            domain,
229            lookups_remaining: self.lookups_remaining.clone(),
230            ..*self
231        }
232    }
233
234    pub(crate) fn check_lookup_limit(&self) -> Result<(), SpfResult> {
235        let remain = self.lookups_remaining.load(Ordering::Relaxed);
236        if remain > 0 {
237            self.lookups_remaining.store(remain - 1, Ordering::Relaxed);
238            return Ok(());
239        }
240
241        Err(SpfResult {
242            disposition: SpfDisposition::PermError,
243            context: "DNS lookup limits exceeded".to_string(),
244        })
245    }
246
247    pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
248        if !initial {
249            if let Err(err) = self.check_lookup_limit() {
250                return err;
251            }
252        }
253
254        let name = match Name::from_str_relaxed(self.domain) {
255            Ok(mut name) => {
256                name.set_fqdn(true);
257                name
258            }
259            Err(_) => {
260                // Per <https://www.rfc-editor.org/rfc/rfc7208#section-4.3>, invalid
261                // domain names yield a "none" result during initial processing.
262                let context = format!("invalid domain name: {}", self.domain);
263                return if initial {
264                    SpfResult {
265                        disposition: SpfDisposition::None,
266                        context,
267                    }
268                } else {
269                    SpfResult {
270                        disposition: SpfDisposition::TempError,
271                        context,
272                    }
273                };
274            }
275        };
276
277        let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
278            Ok(answer) => {
279                if answer.records.is_empty() || answer.nxdomain {
280                    return SpfResult {
281                        disposition: SpfDisposition::None,
282                        context: if answer.records.is_empty() {
283                            format!("no SPF records found for {}", &self.domain)
284                        } else {
285                            format!("domain {} not found", &self.domain)
286                        },
287                    };
288                } else {
289                    answer.as_txt()
290                }
291            }
292            Err(err) => {
293                return SpfResult {
294                    disposition: match err {
295                        DnsError::InvalidName(_) => SpfDisposition::PermError,
296                        DnsError::ResolveFailed(_) => SpfDisposition::TempError,
297                    },
298                    context: format!("{err}"),
299                };
300            }
301        };
302
303        // TXT records can contain all sorts of stuff, let's walk through
304        // the set that we retrieved and take the first one that parses
305        for txt in initial_txt {
306            // a little bit of a layering violation: we need to know
307            // whether we had an SPF record candidate or not to be
308            // able to return an appropriate disposition if they have
309            // TXT records but no SPF records.
310            if txt.starts_with("v=spf1 ") {
311                match Record::parse(&txt) {
312                    Ok(record) => return record.evaluate(self, resolver).await,
313                    Err(err) => {
314                        return SpfResult {
315                            disposition: SpfDisposition::PermError,
316                            context: format!("failed to parse spf record: {err}"),
317                        };
318                    }
319                }
320            }
321        }
322        SpfResult {
323            disposition: SpfDisposition::None,
324            context: format!("no SPF records found for {}", &self.domain),
325        }
326    }
327
328    pub(crate) async fn domain(
329        &self,
330        spec: Option<&MacroSpec>,
331        resolver: &dyn Resolver,
332    ) -> Result<String, SpfResult> {
333        let Some(spec) = spec else {
334            return Ok(self.domain.to_owned());
335        };
336
337        spec.expand(self, resolver).await.map_err(|err| SpfResult {
338            disposition: SpfDisposition::TempError,
339            context: format!("error evaluating domain spec: {err}"),
340        })
341    }
342
343    pub(crate) async fn validated_domain(
344        &self,
345        spec: Option<&MacroSpec>,
346        resolver: &dyn Resolver,
347    ) -> Result<Option<String>, SpfResult> {
348        // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
349        self.check_lookup_limit()?;
350
351        let domain = self.domain(spec, resolver).await?;
352
353        let domain = match Name::from_str_relaxed(&domain) {
354            Ok(domain) => domain,
355            Err(err) => {
356                return Err(SpfResult {
357                    disposition: SpfDisposition::PermError,
358                    context: format!("error parsing domain name: {err}"),
359                })
360            }
361        };
362
363        let ptrs = match resolver.resolve_ptr(self.client_ip).await {
364            Ok(ptrs) => ptrs,
365            Err(err) => {
366                return Err(SpfResult {
367                    disposition: SpfDisposition::TempError,
368                    context: format!("error looking up PTR for {}: {err}", self.client_ip),
369                })
370            }
371        };
372
373        for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
374            if idx >= 10 {
375                // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
376                return Err(SpfResult {
377                    disposition: SpfDisposition::PermError,
378                    context: format!("too many PTR records for {}", self.client_ip),
379                });
380            }
381            match resolver.resolve_ip(&fully_qualify(&ptr.to_string())).await {
382                Ok(ips) => {
383                    if ips.iter().any(|&ip| ip == self.client_ip) {
384                        let mut ptr = ptr.clone();
385                        // Remove trailing dot
386                        ptr.set_fqdn(false);
387                        return Ok(Some(ptr.to_string()));
388                    }
389                }
390                Err(err) => {
391                    return Err(SpfResult {
392                        disposition: SpfDisposition::TempError,
393                        context: format!("error looking up IP for {ptr}: {err}"),
394                    })
395                }
396            }
397        }
398
399        Ok(None)
400    }
401}
402
403pub(crate) fn fully_qualify(domain_name: &str) -> String {
404    match dns_resolver::fully_qualify(domain_name) {
405        Ok(name) => name.to_string(),
406        Err(_) => domain_name.to_string(),
407    }
408}