Skip to main content

mail_auth/spf/
macros.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{Macro, Variable, Variables};
8use crate::SystemTime;
9use crate::dns::{decimal_u8, hex_nibble};
10use std::{borrow::Cow, net::IpAddr};
11
12const DEFAULT_DELIMITERS: u64 = 1u64 << (b'.' - b'+');
13const LAST_DELIMITER: u8 = b'_' - b'+';
14
15impl Macro {
16    /// Expands the macro string using `vars` (RFC 7208, Section 7.3).
17    ///
18    /// Returns `default` for [`Macro::None`]. When `fqdn` is `true`, a
19    /// trailing `.` is appended to a list expansion, and to a variable
20    /// expansion that applies a transformer, unless it already ends in one.
21    /// Literals, and variables expanded verbatim, are returned unchanged.
22    /// Invalid UTF-8 expands to an empty string.
23    pub fn eval<'z, 'x: 'z>(
24        &'z self,
25        vars: &'x Variables<'x>,
26        default: &'x str,
27        fqdn: bool,
28    ) -> Cow<'z, str> {
29        match self {
30            Macro::Literal(literal) => std::str::from_utf8(literal).unwrap_or_default().into(),
31            Macro::Variable {
32                letter,
33                num_parts,
34                reverse,
35                escape,
36                delimiters,
37            } => match vars.get(*letter, *num_parts, *reverse, *escape, fqdn, *delimiters) {
38                Cow::Borrowed(bytes) => std::str::from_utf8(bytes).unwrap_or_default().into(),
39                Cow::Owned(bytes) => String::from_utf8(bytes).unwrap_or_default().into(),
40            },
41            Macro::List(list) => {
42                let mut result = Vec::with_capacity(
43                    list.iter()
44                        .map(|item| match item {
45                            Macro::Literal(literal) => literal.len(),
46                            _ => 24,
47                        })
48                        .sum::<usize>()
49                        + 1,
50                );
51                for item in list {
52                    match item {
53                        Macro::Literal(literal) => {
54                            result.extend_from_slice(literal);
55                        }
56                        Macro::Variable {
57                            letter,
58                            num_parts,
59                            reverse,
60                            escape,
61                            delimiters,
62                        } => {
63                            vars.append(
64                                &mut result,
65                                *letter,
66                                Transform {
67                                    num_parts: *num_parts,
68                                    reverse: *reverse,
69                                    escape: *escape,
70                                    fqdn: false,
71                                    delimiters: *delimiters,
72                                },
73                            );
74                        }
75                        Macro::List(_) | Macro::None => unreachable!(),
76                    }
77                }
78                if fqdn && matches!(result.last(), Some(last) if *last != b'.') {
79                    result.push(b'.');
80                }
81                String::from_utf8(result).unwrap_or_default().into()
82            }
83            Macro::None => default.into(),
84        }
85    }
86
87    /// Returns `true` when the macro string references the `p` macro letter
88    /// ([`Variable::ValidatedDomain`]).
89    pub fn needs_ptr(&self) -> bool {
90        match self {
91            Macro::Variable { letter, .. } => *letter == Variable::ValidatedDomain,
92            Macro::List(list) => list.iter().any(|m| matches!(m, Macro::Variable { letter, .. } if *letter == Variable::ValidatedDomain)),
93            _ => false,
94        }
95    }
96}
97
98impl<'x> Variables<'x> {
99    /// Creates an empty set of variables. The `t` macro
100    /// ([`Variable::CurrentTime`]) expands to the current time unless it is
101    /// set explicitly.
102    pub fn new() -> Self {
103        Variables {
104            current_time_on_demand: true,
105            ..Default::default()
106        }
107    }
108
109    /// Sets the `i`, `v` and `c` macros from the client IP address.
110    pub fn set_ip(&mut self, value: &IpAddr) {
111        let (v, i, c): (&'static [u8], Vec<u8>, Vec<u8>) = match value {
112            IpAddr::V4(ip) => {
113                let mut dotted = Vec::with_capacity(15);
114                let mut buf = [0u8; 3];
115                for octet in ip.octets() {
116                    if !dotted.is_empty() {
117                        dotted.push(b'.');
118                    }
119                    dotted.extend_from_slice(decimal_u8(octet, &mut buf));
120                }
121                (b"in-addr", dotted.clone(), dotted)
122            }
123            IpAddr::V6(ip) => {
124                let mut segments = Vec::with_capacity(63);
125                for segment in ip.segments() {
126                    for shift in [12u32, 8, 4, 0] {
127                        if !segments.is_empty() {
128                            segments.push(b'.');
129                        }
130                        segments.push(hex_nibble((segment >> shift) as u8));
131                    }
132                }
133                (b"ip6", segments, ip.to_string().into_bytes())
134            }
135        };
136        self.vars[Variable::IpVersion as usize] = v.into();
137        self.vars[Variable::Ip as usize] = i.into();
138        self.vars[Variable::SmtpIp as usize] = c.into();
139    }
140
141    /// Sets the `s` macro and, when the value contains `@`, the `l` (local
142    /// part) and `o` (domain) macros.
143    pub fn set_sender(&mut self, value: impl Into<Cow<'x, [u8]>>) {
144        let value = value.into();
145        for (pos, ch) in value.iter().enumerate() {
146            if ch == &b'@' {
147                if pos > 0 {
148                    self.vars[Variable::SenderLocalPart as usize] = match &value {
149                        Cow::Borrowed(value) => (&value[..pos]).into(),
150                        Cow::Owned(value) => value[..pos].to_vec().into(),
151                    };
152                }
153                self.vars[Variable::SenderDomainPart as usize] = match &value {
154                    Cow::Borrowed(value) => (value.get(pos + 1..).unwrap_or_default()).into(),
155                    Cow::Owned(value) => (value.get(pos + 1..).unwrap_or_default()).to_vec().into(),
156                };
157                break;
158            }
159        }
160
161        self.vars[Variable::Sender as usize] = value;
162    }
163
164    /// Sets the `h` macro (HELO or EHLO domain).
165    pub fn set_helo_domain(&mut self, value: impl Into<Cow<'x, [u8]>>) {
166        self.vars[Variable::HeloDomain as usize] = value.into();
167    }
168
169    /// Sets the `r` macro (domain name of the host performing the check).
170    pub fn set_host_domain(&mut self, value: impl Into<Cow<'x, [u8]>>) {
171        self.vars[Variable::HostDomain as usize] = value.into();
172    }
173
174    /// Sets the `p` macro (validated domain name of the client IP).
175    pub fn set_validated_domain(&mut self, value: impl Into<Cow<'x, [u8]>>) {
176        self.vars[Variable::ValidatedDomain as usize] = value.into();
177    }
178
179    /// Sets the `d` macro (the domain being evaluated).
180    pub fn set_domain(&mut self, value: impl Into<Cow<'x, [u8]>>) {
181        self.vars[Variable::Domain as usize] = value.into();
182    }
183
184    /// Returns the value of the variable `name` after applying the macro
185    /// transformers (RFC 7208, Section 7.3).
186    ///
187    /// The value is split on the characters in the `delimiters` bitmask (bit
188    /// `n` stands for the byte `b'+' + n`), reversed when `reverse` is set,
189    /// truncated to the `num_parts` right-hand parts (`0` keeps all), joined
190    /// with `.` and URL-encoded when `escape` is set. When `fqdn` is `true`
191    /// and at least one transformer applies, a trailing `.` is added; a value
192    /// without transformers is returned verbatim.
193    pub fn get(
194        &self,
195        name: Variable,
196        num_parts: u32,
197        reverse: bool,
198        escape: bool,
199        fqdn: bool,
200        delimiters: u64,
201    ) -> Cow<'_, [u8]> {
202        let transform = Transform {
203            num_parts,
204            reverse,
205            escape,
206            fqdn,
207            delimiters,
208        };
209        let var: &[u8] = self.vars[name as usize].as_ref();
210        if var.is_empty() && self.current_time_on_demand && matches!(name, Variable::CurrentTime) {
211            let now = current_time();
212            if transform.is_verbatim() {
213                return Cow::Owned(now);
214            }
215            let mut result = Vec::with_capacity(transform.capacity_for(&now));
216            append_transformed(&mut result, &now, transform);
217            return Cow::Owned(result);
218        }
219        if var.is_empty() || transform.is_verbatim() {
220            return Cow::Borrowed(var);
221        }
222
223        let mut result = Vec::with_capacity(transform.capacity_for(var));
224        append_transformed(&mut result, var, transform);
225        Cow::Owned(result)
226    }
227
228    fn append(&self, result: &mut Vec<u8>, name: Variable, transform: Transform) {
229        let var: &[u8] = self.vars[name as usize].as_ref();
230        if var.is_empty() && self.current_time_on_demand && matches!(name, Variable::CurrentTime) {
231            append_variable(result, &current_time(), transform);
232        } else {
233            append_variable(result, var, transform);
234        }
235    }
236}
237
238#[derive(Clone, Copy)]
239struct Transform {
240    num_parts: u32,
241    reverse: bool,
242    escape: bool,
243    fqdn: bool,
244    delimiters: u64,
245}
246
247impl Transform {
248    #[inline(always)]
249    fn is_verbatim(&self) -> bool {
250        self.num_parts == 0
251            && !self.reverse
252            && !self.escape
253            && self.delimiters == DEFAULT_DELIMITERS
254    }
255
256    #[inline(always)]
257    fn capacity_for(&self, var: &[u8]) -> usize {
258        if self.escape {
259            var.len() * 3 + 1
260        } else {
261            var.len() + 1
262        }
263    }
264
265    #[inline(always)]
266    fn is_delimiter(&self, ch: u8) -> bool {
267        let offset = ch.wrapping_sub(b'+');
268        offset <= LAST_DELIMITER && (self.delimiters & (1u64 << offset)) != 0
269    }
270}
271
272fn append_variable(result: &mut Vec<u8>, var: &[u8], transform: Transform) {
273    if var.is_empty() || transform.is_verbatim() {
274        result.extend_from_slice(var);
275    } else {
276        append_transformed(result, var, transform);
277    }
278}
279
280fn append_transformed(result: &mut Vec<u8>, var: &[u8], transform: Transform) {
281    let skipped = if transform.num_parts == 0 {
282        0
283    } else {
284        let total = 1 + var.iter().filter(|ch| transform.is_delimiter(**ch)).count();
285        total - std::cmp::min(total, transform.num_parts as usize)
286    };
287
288    let start = result.len();
289    if !transform.reverse {
290        for (pos, part) in var
291            .split(|ch| transform.is_delimiter(*ch))
292            .skip(skipped)
293            .enumerate()
294        {
295            add_part(result, part, pos, transform.escape);
296        }
297    } else {
298        for (pos, part) in var
299            .rsplit(|ch| transform.is_delimiter(*ch))
300            .skip(skipped)
301            .enumerate()
302        {
303            add_part(result, part, pos, transform.escape);
304        }
305    }
306    if transform.fqdn && !matches!(result.get(start..), Some([.., b'.'])) {
307        result.push(b'.');
308    }
309}
310
311fn current_time() -> Vec<u8> {
312    let mut seconds = SystemTime::now()
313        .duration_since(SystemTime::UNIX_EPOCH)
314        .map(|d| d.as_secs())
315        .unwrap_or(0);
316    let mut buf = [0u8; 20];
317    let mut len = 0;
318    for slot in buf.iter_mut().rev() {
319        *slot = b'0' + (seconds % 10) as u8;
320        len += 1;
321        seconds /= 10;
322        if seconds == 0 {
323            break;
324        }
325    }
326    buf[buf.len() - len..].to_vec()
327}
328
329#[inline(always)]
330fn add_part(result: &mut Vec<u8>, part: &[u8], pos: usize, escape: bool) {
331    if pos > 0 {
332        result.push(b'.');
333    }
334    if !escape {
335        result.extend_from_slice(part);
336    } else {
337        for &ch in part {
338            if ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'.' | b'_' | b'~') {
339                result.push(ch);
340            } else {
341                result.extend_from_slice(&[b'%', hex_nibble(ch >> 4), hex_nibble(ch)]);
342            }
343        }
344    }
345}
346
347#[cfg(test)]
348mod test {
349    use std::net::IpAddr;
350
351    use crate::spf::{Variables, parse::SPFParser};
352
353    #[test]
354    fn expand_macro() {
355        let mut vars = Variables::new();
356        vars.set_sender("strong-bad@email.example.com".as_bytes());
357        vars.set_ip(&"192.0.2.3".parse::<IpAddr>().unwrap());
358        vars.set_validated_domain("mx.example.org".as_bytes());
359        vars.set_domain("email.example.com".as_bytes());
360        vars.set_helo_domain("....".as_bytes());
361
362        for (macro_string, expansion) in [
363            ("%{s}", "strong-bad@email.example.com"),
364            ("%{o}", "email.example.com"),
365            ("%{d}", "email.example.com"),
366            ("%{d4}", "email.example.com"),
367            ("%{d3}", "email.example.com"),
368            ("%{d2}", "example.com"),
369            ("%{d1}", "com"),
370            ("%{dr}", "com.example.email"),
371            ("%{d2r}", "example.email"),
372            ("%{l}", "strong-bad"),
373            ("%{l-}", "strong.bad"),
374            ("%{lr}", "strong-bad"),
375            ("%{lr-}", "bad.strong"),
376            ("%{l1r-}", "strong"),
377            ("%{p1r}", "mx"),
378            ("%{h3r}", ".."),
379            (
380                "%{ir}.%{v}._spf.%{d2}",
381                "3.2.0.192.in-addr._spf.example.com",
382            ),
383            ("%{lr-}.lp._spf.%{d2}", "bad.strong.lp._spf.example.com"),
384            (
385                "%{lr-}.lp.%{ir}.%{v}._spf.%{d2}",
386                "bad.strong.lp.3.2.0.192.in-addr._spf.example.com",
387            ),
388            (
389                "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}",
390                "3.2.0.192.in-addr.strong.lp._spf.example.com",
391            ),
392            (
393                "%{d2}.trusted-domains.example.net",
394                "example.com.trusted-domains.example.net",
395            ),
396        ] {
397            let (m, _) = macro_string.as_bytes().iter().macro_string(false).unwrap();
398            assert_eq!(m.eval(&vars, "", false), expansion, "{macro_string:?}");
399        }
400
401        let mut vars = Variables::new();
402        vars.set_sender("strong-bad@email.example.com".as_bytes());
403        vars.set_ip(&"2001:db8::cb01".parse::<IpAddr>().unwrap());
404        vars.set_validated_domain("mx.example.org".as_bytes());
405        vars.set_domain("email.example.com".as_bytes());
406
407        for (macro_string, expansion) in [
408            (
409                "%{ir}.%{v}._spf.%{d2}",
410                concat!(
411                    "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.",
412                    "0.0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com"
413                ),
414            ),
415            ("%{c}", "2001:db8::cb01"),
416            (
417                "%{c} is not one of %{d}'s designated mail servers.",
418                "2001:db8::cb01 is not one of email.example.com's designated mail servers.",
419            ),
420            (
421                "See http://%{d}/why.html?s=%{S}&i=%{C}",
422                concat!(
423                    "See http://email.example.com/why.html?",
424                    "s=strong-bad%40email.example.com&i=2001%3adb8%3a%3acb01"
425                ),
426            ),
427        ] {
428            let (m, _) = macro_string.as_bytes().iter().macro_string(true).unwrap();
429            assert_eq!(m.eval(&vars, "", false), expansion, "{macro_string:?}");
430        }
431    }
432}