mta_sts/
lib.rs

1use dns_resolver::Resolver;
2use futures::future::BoxFuture;
3use hickory_resolver::proto::rr::Name;
4use policy::MtaStsPolicy;
5use std::collections::BTreeMap;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9lruttl::declare_cache! {
10/// Caches MTA-STS policy information by domain
11static CACHE: LruCacheWithTtl<Name, CachedPolicy>::new("mta_sts_policy", 64 * 1024);
12}
13
14pub mod dns;
15pub mod policy;
16
17#[derive(Clone, Debug)]
18struct CachedPolicy {
19    pub id: String,
20    pub policy: Arc<MtaStsPolicy>,
21}
22
23struct Getter {}
24
25impl policy::Get for Getter {
26    fn http_get<'a>(&'a self, url: &'a str) -> BoxFuture<'a, anyhow::Result<String>> {
27        Box::pin(async move {
28            let response = reqwest::Client::builder()
29                // <https://datatracker.ietf.org/doc/html/rfc8461#section-3.3>
30                // HTTP 3xx redirects MUST NOT be followed
31                .redirect(reqwest::redirect::Policy::none())
32                .timeout(std::time::Duration::from_secs(20))
33                .build()?
34                .request(reqwest::Method::GET, url)
35                .send()
36                .await?;
37
38            // <https://datatracker.ietf.org/doc/html/rfc8461#section-3.3>
39            // Policies fetched via HTTPS are only valid if the HTTP
40            // response code is 200 (OK)
41            let status = response.status();
42            if status != reqwest::StatusCode::OK {
43                anyhow::bail!("failed to GET {url}: {status}");
44            }
45
46            // <https://datatracker.ietf.org/doc/html/rfc8461#section-3.2>
47            // senders SHOULD validate that the media type is "text/plain"
48            // to guard against cases where web servers allow untrusted users
49            // to host non-text content.
50            // We need to do some manual grubbing about for this, as reqwest's
51            // Response::text() method doesn't verify that the type is textual,
52            // just whether it decodes as text, which is precisely what we're
53            // trying to guard against.
54
55            let content_type = response
56                .headers()
57                .get(reqwest::header::CONTENT_TYPE)
58                .ok_or_else(|| anyhow::anyhow!("missing required Content-Type header"))?;
59
60            let content_type = content_type.to_str()?;
61
62            let ct = if let Some((ct, _)) = content_type.split_once(';') {
63                ct.trim()
64            } else {
65                content_type.trim()
66            };
67            if ct != "text/plain" {
68                anyhow::bail!("Content-Type must be text/plain, got {content_type}");
69            }
70
71            Ok(response.text().await?)
72        })
73    }
74}
75
76/// Test hook. When set, [`get_policy_for_domain`] serves policies from this
77/// fixed map instead of performing live DNS TXT + HTTPS fetching. A domain
78/// absent from the map resolves to an error, exactly like a domain that
79/// publishes no MTA-STS record.
80static TEST_POLICIES: Mutex<Option<Arc<BTreeMap<String, Arc<MtaStsPolicy>>>>> = Mutex::new(None);
81
82/// Install a fixed set of MTA-STS policies, keyed by policy domain (no trailing
83/// dot), for use in tests. While installed, all policy resolution is served
84/// from this map without touching the network.
85pub fn set_test_policies(policies: BTreeMap<String, MtaStsPolicy>) {
86    let map = policies
87        .into_iter()
88        .map(|(domain, policy)| (domain, Arc::new(policy)))
89        .collect();
90    *TEST_POLICIES.lock().unwrap() = Some(Arc::new(map));
91}
92
93pub async fn get_policy_for_domain(
94    policy_domain: &str,
95    resolver: Option<&dyn Resolver>,
96) -> anyhow::Result<Arc<MtaStsPolicy>> {
97    if let Some(policies) = TEST_POLICIES.lock().unwrap().clone() {
98        let domain = policy_domain.trim_end_matches('.');
99        return policies
100            .get(domain)
101            .cloned()
102            .ok_or_else(|| anyhow::anyhow!("no MTA-STS policy for {domain}"));
103    }
104
105    match resolver {
106        Some(resolver) => get_policy_for_domain_impl(policy_domain, resolver, &Getter {}).await,
107        None => {
108            let resolver = dns_resolver::get_resolver();
109            get_policy_for_domain_impl(policy_domain, &**resolver, &Getter {}).await
110        }
111    }
112}
113
114fn cache_lookup(name: &Name) -> Option<CachedPolicy> {
115    CACHE.get(name)
116}
117
118async fn get_policy_for_domain_impl(
119    policy_domain: &str,
120    resolver: &dyn Resolver,
121    getter: &dyn policy::Get,
122) -> anyhow::Result<Arc<MtaStsPolicy>> {
123    let name = Name::from_str_relaxed(policy_domain)?.to_lowercase();
124
125    if let Some(cached) = cache_lookup(&name) {
126        // Removal of the DNS record does not invalidate our
127        // cached result, only updating it with a different id
128        let still_valid = dns::resolve_dns_record(policy_domain, resolver)
129            .await
130            .map(|r| cached.id == r.id)
131            .unwrap_or(true);
132
133        if still_valid {
134            return Ok(Arc::clone(&cached.policy));
135        }
136    }
137
138    let record = dns::resolve_dns_record(policy_domain, resolver).await?;
139
140    let policy = Arc::new(policy::load_policy_for_domain(policy_domain, getter).await?);
141
142    let expires = Instant::now() + Duration::from_secs(policy.max_age);
143
144    CACHE
145        .insert(
146            name,
147            CachedPolicy {
148                id: record.id,
149                policy: Arc::clone(&policy),
150            },
151            expires.into(),
152        )
153        .await;
154
155    Ok(policy)
156}
157
158/*
159#[cfg(test)]
160mod test {
161    use super::*;
162
163    #[tokio::test]
164    async fn get_gmail_policy() {
165        k9::snapshot!(
166            get_policy_for_domain("gmail.com").await.unwrap(),
167            r#"
168MtaStsPolicy {
169    mode: Enforce,
170    mx: [
171        "gmail-smtp-in.l.google.com",
172        "*.gmail-smtp-in.l.google.com",
173    ],
174    max_age: 86400,
175    fields: {},
176}
177"#
178        );
179    }
180}
181*/