Skip to main content

codoseo_web/
abuse.rs

1//! Abuse controls for the public audit (spec section 8): who is asking (the client address and
2//! its daily-salted hash) and which email domains are throwaways. The limits themselves live in
3//! the store, next to the data they count; Turnstile is in [`crate::turnstile`].
4
5use std::convert::Infallible;
6use std::net::{IpAddr, SocketAddr};
7
8use axum::extract::{ConnectInfo, FromRequestParts};
9use axum::http::HeaderMap;
10use axum::http::request::Parts;
11use sha2::{Digest, Sha256};
12use time::Date;
13
14use crate::config::{Config, Mode};
15use crate::state::AppState;
16
17/// The visitor's address as far as limits are concerned. `None` only when it can't be told
18/// (no proxy header and no socket address, which doesn't happen when serving), in which case
19/// the per-IP limits don't apply.
20#[derive(Debug, Clone, Copy)]
21pub struct ClientIp(pub Option<IpAddr>);
22
23impl FromRequestParts<AppState> for ClientIp {
24    type Rejection = Infallible;
25
26    async fn from_request_parts(
27        parts: &mut Parts,
28        state: &AppState,
29    ) -> Result<ClientIp, Infallible> {
30        let peer = parts
31            .extensions
32            .get::<ConnectInfo<SocketAddr>>()
33            .map(|c| c.0.ip());
34        Ok(ClientIp(client_ip(&parts.headers, peer, &state.config)))
35    }
36}
37
38/// In the cloud, the address the proxy in front of us reports (`CLIENT_IP_HEADER`, by default
39/// `CF-Connecting-IP`); everywhere else, and when that header is missing or isn't an address,
40/// the socket's peer. Self-hosted instances never trust a client-supplied header.
41///
42/// The cloud must only be reachable through Cloudflare for the header to be trustworthy.
43pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, config: &Config) -> Option<IpAddr> {
44    if config.mode == Mode::Cloud
45        && let Some(ip) = headers
46            .get(config.client_ip_header.as_str())
47            .and_then(|v| v.to_str().ok())
48            .and_then(|v| v.trim().parse::<IpAddr>().ok())
49    {
50        return Some(ip);
51    }
52    peer
53}
54
55/// What is stored on a crawl instead of the visitor's address: SHA-256 over a salt that
56/// changes every UTC day (derived from the instance secret), then the address. Yesterday's
57/// hashes can't be matched to today's, so the counters can't build a history of a person.
58/// IPv6 addresses count by their /64, since a person controls every address in it.
59pub fn ip_hash(secret: &str, ip: IpAddr, day: Date) -> Vec<u8> {
60    let salt = Sha256::digest(format!("codoseo.ip-salt:{secret}:{day}").as_bytes());
61    let mut hash = Sha256::new();
62    hash.update(salt);
63    hash.update(normalise(ip).as_bytes());
64    hash.finalize().to_vec()
65}
66
67fn normalise(ip: IpAddr) -> String {
68    match ip {
69        IpAddr::V4(v4) => v4.to_string(),
70        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
71            Some(v4) => v4.to_string(),
72            None => {
73                let s = v6.segments();
74                format!("{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3])
75            }
76        },
77    }
78}
79
80/// Throwaway-inbox providers. One free account per email only means something if an email
81/// costs more than a click. Matches the domain and any subdomain of it.
82const DISPOSABLE: &[&str] = &[
83    "10minutemail.co.uk",
84    "10minutemail.com",
85    "10minutemail.net",
86    "20minutemail.com",
87    "anonbox.net",
88    "burnermail.io",
89    "byom.de",
90    "crazymailing.com",
91    "discard.email",
92    "discardmail.com",
93    "dispostable.com",
94    "emailfake.com",
95    "emailondeck.com",
96    "fakeinbox.com",
97    "fakemail.net",
98    "fexpost.com",
99    "getairmail.com",
100    "getnada.com",
101    "grr.la",
102    "guerrillamail.biz",
103    "guerrillamail.com",
104    "guerrillamail.de",
105    "guerrillamail.net",
106    "guerrillamail.org",
107    "guerrillamailblock.com",
108    "harakirimail.com",
109    "inboxkitten.com",
110    "jetable.org",
111    "mail.tm",
112    "mailcatch.com",
113    "maildrop.cc",
114    "mailforspam.com",
115    "mailinator.com",
116    "mailnesia.com",
117    "mailto.plus",
118    "minuteinbox.com",
119    "mintemail.com",
120    "moakt.com",
121    "mohmal.com",
122    "mytemp.email",
123    "nada.email",
124    "owlymail.com",
125    "sharklasers.com",
126    "spam4.me",
127    "spamgourmet.com",
128    "temp-mail.io",
129    "temp-mail.org",
130    "tempinbox.com",
131    "tempmail.com",
132    "tempmail.net",
133    "tempmailo.com",
134    "tempr.email",
135    "throwawaymail.com",
136    "tmail.ws",
137    "trash-mail.com",
138    "trashmail.com",
139    "trashmail.de",
140    "trashmail.net",
141    "trbvm.com",
142    "yopmail.com",
143    "yopmail.fr",
144    "yopmail.net",
145];
146
147pub fn is_disposable(address: &str) -> bool {
148    let domain = address
149        .rsplit_once('@')
150        .map_or(address, |(_, d)| d)
151        .trim()
152        .trim_end_matches('.')
153        .to_ascii_lowercase();
154    DISPOSABLE
155        .iter()
156        .any(|d| domain == *d || domain.ends_with(&format!(".{d}")))
157}
158
159/// `about 59 minutes`, `about 22 hours`: how long until a limit lifts.
160pub fn wait_text(secs: i64) -> String {
161    if secs < 90 * 60 {
162        let minutes = (secs + 59) / 60;
163        format!(
164            "about {} minute{}",
165            minutes.max(1),
166            if minutes == 1 { "" } else { "s" }
167        )
168    } else {
169        let hours = (secs + 3599) / 3600;
170        format!("about {hours} hours")
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use axum::http::HeaderValue;
178
179    fn cloud() -> Config {
180        Config::from_lookup(|k| match k {
181            "CODOSEO_MODE" => Some("cloud".into()),
182            "BASE_URL" => Some("https://codoseo.com".into()),
183            "SECRET_KEY" => Some("k".into()),
184            "SMTP_URL" => Some("smtp://127.0.0.1:2525".into()),
185            _ => None,
186        })
187        .unwrap()
188    }
189
190    fn with_header(value: &str) -> HeaderMap {
191        let mut h = HeaderMap::new();
192        h.insert("cf-connecting-ip", HeaderValue::from_str(value).unwrap());
193        h
194    }
195
196    #[test]
197    fn the_cloud_trusts_its_proxy_header_and_nothing_else() {
198        let peer: IpAddr = "10.0.0.1".parse().unwrap();
199        let cfg = cloud();
200        assert_eq!(
201            client_ip(&with_header("203.0.113.9"), Some(peer), &cfg),
202            Some("203.0.113.9".parse().unwrap())
203        );
204        assert_eq!(
205            client_ip(&with_header(" 2001:db8::1 "), Some(peer), &cfg),
206            Some("2001:db8::1".parse().unwrap())
207        );
208        // Garbage or a missing header falls back to the socket.
209        assert_eq!(
210            client_ip(&with_header("not an ip"), Some(peer), &cfg),
211            Some(peer)
212        );
213        assert_eq!(client_ip(&HeaderMap::new(), Some(peer), &cfg), Some(peer));
214        // `X-Forwarded-For` is the client's to forge: never read.
215        let mut h = HeaderMap::new();
216        h.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.9"));
217        assert_eq!(client_ip(&h, Some(peer), &cfg), Some(peer));
218        assert_eq!(client_ip(&HeaderMap::new(), None, &cfg), None);
219    }
220
221    #[test]
222    fn self_hosted_ignores_the_header() {
223        let peer: IpAddr = "192.168.1.5".parse().unwrap();
224        let cfg = Config::for_tests();
225        assert_eq!(
226            client_ip(&with_header("203.0.113.9"), Some(peer), &cfg),
227            Some(peer)
228        );
229    }
230
231    #[test]
232    fn hashes_depend_on_secret_day_and_address() {
233        let day = Date::from_calendar_date(2026, time::Month::October, 5).unwrap();
234        let ip: IpAddr = "203.0.113.9".parse().unwrap();
235        let h = ip_hash("s", ip, day);
236        assert_eq!(h.len(), 32);
237        assert_eq!(h, ip_hash("s", ip, day));
238        assert_ne!(h, ip_hash("s", ip, day.next_day().unwrap()));
239        assert_ne!(h, ip_hash("t", ip, day));
240        assert_ne!(h, ip_hash("s", "203.0.113.10".parse().unwrap(), day));
241    }
242
243    #[test]
244    fn ipv6_counts_by_its_64_and_mapped_v4_by_its_v4() {
245        let day = Date::from_calendar_date(2026, time::Month::October, 5).unwrap();
246        let a: IpAddr = "2001:db8:1:2:aaaa::1".parse().unwrap();
247        let b: IpAddr = "2001:db8:1:2:bbbb::ffff".parse().unwrap();
248        let other: IpAddr = "2001:db8:1:3::1".parse().unwrap();
249        assert_eq!(ip_hash("s", a, day), ip_hash("s", b, day));
250        assert_ne!(ip_hash("s", a, day), ip_hash("s", other, day));
251        assert_eq!(
252            ip_hash("s", "::ffff:203.0.113.9".parse().unwrap(), day),
253            ip_hash("s", "203.0.113.9".parse().unwrap(), day)
254        );
255    }
256
257    #[test]
258    fn disposable_domains_match_subdomains_but_not_lookalikes() {
259        for bad in [
260            "a@mailinator.com",
261            "A@MAILINATOR.COM",
262            "a@x.mailinator.com",
263            "a@yopmail.com.",
264        ] {
265            assert!(is_disposable(bad), "{bad}");
266        }
267        for ok in [
268            "a@gmail.com",
269            "a@notmailinator.com",
270            "a@mailinator.example.org",
271            "a@company.io",
272        ] {
273            assert!(!is_disposable(ok), "{ok}");
274        }
275    }
276
277    #[test]
278    fn waits_read_naturally() {
279        assert_eq!(wait_text(1), "about 1 minute");
280        assert_eq!(wait_text(3590), "about 60 minutes");
281        assert_eq!(wait_text(5400), "about 2 hours");
282        assert_eq!(wait_text(80_000), "about 23 hours");
283    }
284}