mta_sts/
policy.rs

1use futures::future::BoxFuture;
2use std::collections::BTreeMap;
3
4/// Upper bound RFC 8461 places on the max_age field, in seconds (roughly one
5/// year). Values above this are clamped on parse: honoring an arbitrarily large
6/// lifetime would allow a remote domain to pin a policy far longer than the
7/// spec permits, and the clamped value keeps expiry arithmetic well within
8/// range.
9pub const MAX_AGE_CAP: u64 = 31557600;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
12pub enum PolicyMode {
13    Enforce,
14    Testing,
15    None,
16}
17
18#[derive(Debug)]
19pub struct MtaStsPolicy {
20    pub mode: PolicyMode,
21    pub mx: Vec<String>,
22    pub max_age: u64,
23    pub fields: BTreeMap<String, Vec<String>>,
24}
25
26impl MtaStsPolicy {
27    pub fn parse(data: &str) -> anyhow::Result<Self> {
28        let mut fields: BTreeMap<String, Vec<String>> = BTreeMap::new();
29
30        for line in data.lines() {
31            if line.is_empty() {
32                continue;
33            }
34            let (key, value) = line
35                .split_once(':')
36                .ok_or_else(|| anyhow::anyhow!("invalid STS policy {data}"))?;
37            let key = key.trim();
38            let value = value.trim();
39
40            fields
41                .entry(key.to_string())
42                .or_default()
43                .push(value.to_string());
44        }
45
46        let version = fields
47            .remove("version")
48            .ok_or_else(|| anyhow::anyhow!("STS policy {data} is missing a version"))?;
49        if version.len() != 1 || version[0] != "STSv1" {
50            anyhow::bail!("STS policy {data} has incompatible STS version");
51        }
52
53        let mode = match fields.remove("mode") {
54            None => anyhow::bail!("STS policy {data} is missing required mode"),
55            Some(mode) if mode.len() == 1 => match mode[0].as_str() {
56                "enforce" => PolicyMode::Enforce,
57                "testing" => PolicyMode::Testing,
58                "none" => PolicyMode::None,
59                _ => anyhow::bail!("STS policy {data} has invalid mode"),
60            },
61            _ => anyhow::bail!("STS policy {data} has invalid mode"),
62        };
63
64        let mut mx = match fields.remove("mx") {
65            None if mode == PolicyMode::None => vec![],
66            None => anyhow::bail!("STS policy {data} is missing required mx"),
67            Some(v) => v,
68        };
69
70        // Ensure that the mx entries are lowercased to aid
71        // the mx_name_matches method
72        mx.iter_mut()
73            .for_each(|entry| *entry = entry.to_lowercase());
74
75        let max_age: u64 = match fields.remove("max_age") {
76            None => anyhow::bail!("STS policy {data} is missing required max_age"),
77            Some(v) if v.len() == 1 => {
78                let max_age = &v[0];
79                let max_age: u64 = max_age.parse().map_err(|err| {
80                    anyhow::anyhow!(
81                        "STS policy {data} has max_age {max_age} \
82                        that is not a valid integer: {err:#}"
83                    )
84                })?;
85                max_age.min(MAX_AGE_CAP)
86            }
87            _ => anyhow::bail!("STS policy {data} has invalid max_age"),
88        };
89
90        Ok(Self {
91            fields,
92            mode,
93            mx,
94            max_age,
95        })
96    }
97
98    /// Returns true if `name` matches any of the allowed mx
99    /// host name patterns.
100    /// `name` must be lowercase.
101    pub fn mx_name_matches(&self, name: &str) -> bool {
102        for pattern in &self.mx {
103            if name_match(name, pattern) {
104                return true;
105            }
106        }
107        false
108    }
109}
110
111fn name_match(name: &str, pattern: &str) -> bool {
112    // kumo uses canonicalized names that include a trailing period.
113    // remove that from the name when matching against a pattern.
114    let name = name.trim_end_matches('.');
115
116    if pattern.starts_with("*.") {
117        let suffix = &pattern[1..];
118        if let Some(lhs) = name.strip_suffix(suffix) {
119            // Wildcards only match the first component
120            return lhs.find('.').is_none();
121        }
122        false
123    } else {
124        name == pattern
125    }
126}
127
128pub trait Get: Sync + Send {
129    fn http_get<'a>(&'a self, url: &'a str) -> BoxFuture<'a, anyhow::Result<String>>;
130}
131
132pub async fn load_policy_for_domain(
133    policy_domain: &str,
134    getter: &dyn Get,
135) -> anyhow::Result<MtaStsPolicy> {
136    // kumo uses canonicalized names that include a trailing period.
137    // remove that from the policy_domain to avoid extraneous redirects
138    // on some remote servers
139    let policy_domain = policy_domain.trim_end_matches('.');
140    let url = format!("https://mta-sts.{policy_domain}/.well-known/mta-sts.txt");
141    let policy = getter.http_get(&url).await?;
142    MtaStsPolicy::parse(&policy)
143}
144
145#[cfg(test)]
146pub(crate) mod test {
147    use super::*;
148
149    pub struct TestGetter {
150        policies: BTreeMap<&'static str, &'static str>,
151    }
152
153    impl TestGetter {
154        pub fn new<I: IntoIterator<Item = (&'static str, &'static str)>>(iter: I) -> Self {
155            Self {
156                policies: BTreeMap::from_iter(iter),
157            }
158        }
159    }
160
161    impl Get for TestGetter {
162        fn http_get<'a>(&'a self, url: &'a str) -> BoxFuture<'a, anyhow::Result<String>> {
163            Box::pin(async move {
164                match self.policies.get(url) {
165                    Some(result) => Ok(result.to_string()),
166                    None => anyhow::bail!("404 {url}"),
167                }
168            })
169        }
170    }
171
172    const SAMPLE_POLICY: &str =
173        "version: STSv1 \nmode: enforce\nmx: mail.example.com\r\nmx:\t*.example.net\nmx: backupmx.example.com\nmax_age: 604800";
174
175    #[tokio::test]
176    async fn get_policy() {
177        let getter = TestGetter::new([(
178            "https://mta-sts.example.com/.well-known/mta-sts.txt",
179            SAMPLE_POLICY,
180        )]);
181
182        k9::snapshot!(
183            load_policy_for_domain("example.com.", &getter)
184                .await
185                .unwrap(),
186            r#"
187MtaStsPolicy {
188    mode: Enforce,
189    mx: [
190        "mail.example.com",
191        "*.example.net",
192        "backupmx.example.com",
193    ],
194    max_age: 604800,
195    fields: {},
196}
197"#
198        );
199    }
200
201    #[test]
202    fn parse_policy() {
203        k9::snapshot!(
204            MtaStsPolicy::parse(SAMPLE_POLICY).unwrap(),
205            r#"
206MtaStsPolicy {
207    mode: Enforce,
208    mx: [
209        "mail.example.com",
210        "*.example.net",
211        "backupmx.example.com",
212    ],
213    max_age: 604800,
214    fields: {},
215}
216"#
217        );
218    }
219
220    #[test]
221    fn parse_clamps_oversized_max_age() {
222        let policy = MtaStsPolicy::parse(
223            "version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 18446744073709551615",
224        )
225        .unwrap();
226        k9::assert_equal!(policy.max_age, MAX_AGE_CAP);
227    }
228
229    #[test]
230    fn name_matching() {
231        assert!(name_match("foo.com", "foo.com"));
232        assert!(name_match("foo.com.", "foo.com"));
233        assert!(!name_match("bar.com", "foo.com"));
234        assert!(name_match("foo.com", "*.com"));
235        assert!(name_match("mx.example.com", "*.example.com"));
236        assert!(!name_match("not.mx.example.com", "*.example.com"));
237        assert!(!name_match("example.com", "*.example.com"));
238    }
239}