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