Skip to main content

ojak/
client.rs

1//! The HTTP client every request Ojak sends goes through.
2//!
3//! An ActivityPub server fetches and posts to URLs other servers name, so
4//! anything that reaches its network is reachable by anyone who can name a
5//! URL. This client refuses the addresses a public server has no business
6//! contacting — loopback, private, link-local, shared and reserved ranges —
7//! and it decides on the addresses DNS returns, after resolution, so that a
8//! name that passed a check cannot be pointed somewhere else before the
9//! connection is made. Literal IP addresses in a URL, and every hop of a
10//! redirect, are checked the same way.
11//!
12//! An application running against a peer on its own network, in development
13//! or tests, allows that network explicitly with
14//! [`ClientConfig::allow_private`].
15
16use ipnet::IpNet;
17use reqwest::dns::{Addrs, Name, Resolve, Resolving};
18use reqwest::header::HeaderMap;
19use std::fmt;
20use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
21use std::sync::Arc;
22use std::time::Duration;
23use url::Url;
24
25/// How the client behaves.
26#[derive(Clone, Debug)]
27pub struct ClientConfig {
28    /// Networks the client may reach although they are not public.
29    pub allow_private: Vec<IpNet>,
30    /// The `User-Agent` sent with every request.
31    pub user_agent: String,
32    /// How long a connection may take to open.
33    pub connect_timeout: Duration,
34    /// How long a whole request may take, response included.
35    pub timeout: Duration,
36    /// The largest response body read; a longer one is an error.
37    pub max_response_bytes: usize,
38    /// How many redirects a GET follows.
39    pub max_redirects: usize,
40    /// How long a connection is kept open, unused, for another request to
41    /// the same host.
42    pub pool_idle_timeout: Duration,
43    /// How many unused connections are kept open to one host.
44    ///
45    /// A fan-out sends to thousands of hosts once each, and every connection
46    /// kept for reuse is a socket and its buffers: left unbounded, as the
47    /// HTTP client's defaults leave it, a post to 9,258 servers held 3,900
48    /// sockets and 370 MiB. Mastodon closes a connection after 30 seconds
49    /// idle and keeps 512 at most.
50    pub pool_max_idle_per_host: usize,
51}
52
53impl Default for ClientConfig {
54    fn default() -> Self {
55        Self {
56            allow_private: Vec::new(),
57            user_agent: concat!("ojak/", env!("CARGO_PKG_VERSION")).to_owned(),
58            connect_timeout: Duration::from_secs(10),
59            timeout: Duration::from_secs(30),
60            max_response_bytes: 1024 * 1024,
61            max_redirects: 3,
62            pool_idle_timeout: Duration::from_secs(10),
63            pool_max_idle_per_host: 2,
64        }
65    }
66}
67
68/// Why a request was not answered.
69#[derive(Debug)]
70pub enum RequestError {
71    /// The URL is not an `http` or `https` URL with a host.
72    InvalidUrl(String),
73    /// The URL's host is, or resolves only to, an address the client
74    /// refuses.
75    Refused(String),
76    /// The response was larger than [`ClientConfig::max_response_bytes`].
77    TooLarge,
78    /// The connection failed, timed out, or broke.
79    Network(reqwest::Error),
80}
81
82impl fmt::Display for RequestError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::InvalidUrl(url) => write!(f, "not a URL Ojak will request: {url}"),
86            Self::Refused(why) => write!(f, "refused: {why}"),
87            Self::TooLarge => f.write_str("response too large"),
88            Self::Network(error) => write!(f, "network: {error}"),
89        }
90    }
91}
92
93impl std::error::Error for RequestError {}
94
95/// A response, read in full.
96#[derive(Clone, Debug)]
97pub struct Response {
98    pub status: u16,
99    pub headers: HeaderMap,
100    /// Where the response came from, after any redirects.
101    pub url: Url,
102    pub body: Vec<u8>,
103}
104
105/// The guarded client. Cheap to clone; clones share a connection pool.
106#[derive(Clone, Debug)]
107pub struct Client {
108    get: reqwest::Client,
109    /// Follows no redirect.
110    direct: reqwest::Client,
111    config: Arc<ClientConfig>,
112}
113
114impl Client {
115    /// Build a client.
116    ///
117    /// # Errors
118    ///
119    /// When the TLS backend cannot be initialised.
120    pub fn new(config: ClientConfig) -> Result<Self, RequestError> {
121        let config = Arc::new(config);
122        let resolver = Arc::new(GuardedResolver {
123            allow: config.allow_private.clone(),
124        });
125        let builder = || {
126            reqwest::Client::builder()
127                .user_agent(config.user_agent.clone())
128                .connect_timeout(config.connect_timeout)
129                .timeout(config.timeout)
130                .pool_idle_timeout(config.pool_idle_timeout)
131                .pool_max_idle_per_host(config.pool_max_idle_per_host)
132                .dns_resolver(resolver.clone())
133        };
134        let redirect_config = config.clone();
135        let get = builder()
136            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
137                if attempt.previous().len() > redirect_config.max_redirects {
138                    return attempt.error("too many redirects");
139                }
140                match validate_url(attempt.url(), &redirect_config.allow_private) {
141                    Ok(()) => attempt.follow(),
142                    Err(RequestError::Refused(why)) => attempt.error(Refused(why)),
143                    Err(error) => attempt.error(error.to_string()),
144                }
145            }))
146            .build()
147            .map_err(RequestError::Network)?;
148        // A delivery is not redirected: the inbox is where the signature says
149        // it is going, and a redirected POST would carry the body elsewhere.
150        // A signed GET follows its redirects itself, signing each hop for
151        // where it goes.
152        let direct = builder()
153            .redirect(reqwest::redirect::Policy::none())
154            .build()
155            .map_err(RequestError::Network)?;
156        Ok(Self {
157            get,
158            direct,
159            config,
160        })
161    }
162
163    /// The configuration the client was built with.
164    #[must_use]
165    pub fn config(&self) -> &ClientConfig {
166        &self.config
167    }
168
169    /// GET `url`, following redirects.
170    ///
171    /// # Errors
172    ///
173    /// When the URL or an address is refused, the response is too large, or
174    /// the request fails. A response with any status is `Ok`.
175    pub async fn get(&self, url: &Url, headers: HeaderMap) -> Result<Response, RequestError> {
176        validate_url(url, &self.config.allow_private)?;
177        let response = self
178            .get
179            .get(url.clone())
180            .headers(headers)
181            .send()
182            .await
183            .map_err(classify)?;
184        self.read(response).await
185    }
186
187    /// GET `url` without following a redirect: a 3xx is the response.
188    ///
189    /// # Errors
190    ///
191    /// As [`Client::get`].
192    pub async fn get_direct(
193        &self,
194        url: &Url,
195        headers: HeaderMap,
196    ) -> Result<Response, RequestError> {
197        validate_url(url, &self.config.allow_private)?;
198        let response = self
199            .direct
200            .get(url.clone())
201            .headers(headers)
202            .send()
203            .await
204            .map_err(classify)?;
205        self.read(response).await
206    }
207
208    /// POST `body` to `url`, without following redirects.
209    ///
210    /// # Errors
211    ///
212    /// As [`Client::get`].
213    pub async fn post(
214        &self,
215        url: &Url,
216        headers: HeaderMap,
217        body: Vec<u8>,
218    ) -> Result<Response, RequestError> {
219        validate_url(url, &self.config.allow_private)?;
220        let response = self
221            .direct
222            .post(url.clone())
223            .headers(headers)
224            .body(body)
225            .send()
226            .await
227            .map_err(classify)?;
228        self.read(response).await
229    }
230
231    /// A request to `url`, following redirects, for the application to add
232    /// to, send and read itself: a GET of something that is not a document,
233    /// such as a link's preview or a media file, whose body may be large and
234    /// is better read as a stream than all at once.
235    ///
236    /// The request goes through the same guard as every other, `url` here
237    /// and each address and redirect when it is sent, with the client's
238    /// connect and overall timeouts, either of which the builder may
239    /// shorten. Nothing bounds the body but how the caller reads it:
240    /// [`ClientConfig::max_response_bytes`] applies only to what the client
241    /// reads in full. An error from sending it converts into a
242    /// [`RequestError`], a refusal included.
243    ///
244    /// # Errors
245    ///
246    /// When `url` is refused, as [`validate_url`].
247    pub fn request(
248        &self,
249        method: reqwest::Method,
250        url: &Url,
251    ) -> Result<reqwest::RequestBuilder, RequestError> {
252        validate_url(url, &self.config.allow_private)?;
253        Ok(self.get.request(method, url.clone()))
254    }
255
256    async fn read(&self, mut response: reqwest::Response) -> Result<Response, RequestError> {
257        let status = response.status().as_u16();
258        let headers = response.headers().clone();
259        let url = response.url().clone();
260        let limit = self.config.max_response_bytes;
261        if response
262            .content_length()
263            .is_some_and(|length| length > limit as u64)
264        {
265            return Err(RequestError::TooLarge);
266        }
267        let mut body = Vec::new();
268        while let Some(chunk) = response.chunk().await.map_err(RequestError::Network)? {
269            if body.len() + chunk.len() > limit {
270                return Err(RequestError::TooLarge);
271            }
272            body.extend_from_slice(&chunk);
273        }
274        Ok(Response {
275            status,
276            headers,
277            url,
278            body,
279        })
280    }
281}
282
283impl From<reqwest::Error> for RequestError {
284    /// A refusal from the resolver or a redirect comes back as
285    /// [`RequestError::Refused`]; anything else is
286    /// [`RequestError::Network`].
287    fn from(error: reqwest::Error) -> Self {
288        classify(error)
289    }
290}
291
292/// A refusal from the resolver or a redirect surfaces inside reqwest's error;
293/// bring it back out as what it is.
294fn classify(error: reqwest::Error) -> RequestError {
295    let mut source: Option<&dyn std::error::Error> = Some(&error);
296    while let Some(current) = source {
297        if let Some(refused) = current.downcast_ref::<Refused>() {
298            return RequestError::Refused(refused.0.clone());
299        }
300        source = current.source();
301    }
302    RequestError::Network(error)
303}
304
305/// A refusal, carried through reqwest's error types from the resolver or the
306/// redirect policy.
307#[derive(Debug)]
308struct Refused(String);
309
310impl fmt::Display for Refused {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        write!(f, "refused: {}", self.0)
313    }
314}
315
316impl std::error::Error for Refused {}
317
318/// Check a URL before requesting it: its scheme, and its host when the host
319/// is a literal address, which DNS never sees.
320///
321/// Every request [`Client`] sends is checked with this first, and a name is
322/// checked on the addresses it resolves to when the connection is made; an
323/// application that sends a request some other way, or wants to refuse a URL
324/// before it gets that far, checks it here against the same `allow` list.
325///
326/// # Errors
327///
328/// [`RequestError::InvalidUrl`] when the URL is not `http` or `https` with a
329/// host, and [`RequestError::Refused`] when its host is an address neither
330/// public nor in `allow`.
331pub fn validate_url(url: &Url, allow: &[IpNet]) -> Result<(), RequestError> {
332    if !matches!(url.scheme(), "http" | "https") {
333        return Err(RequestError::InvalidUrl(url.to_string()));
334    }
335    match url.host() {
336        None => Err(RequestError::InvalidUrl(url.to_string())),
337        Some(url::Host::Ipv4(address)) => check_address(IpAddr::V4(address), allow),
338        Some(url::Host::Ipv6(address)) => check_address(IpAddr::V6(address), allow),
339        Some(url::Host::Domain(_)) => Ok(()),
340    }
341}
342
343fn check_address(address: IpAddr, allow: &[IpNet]) -> Result<(), RequestError> {
344    if permitted(address, allow) {
345        Ok(())
346    } else {
347        Err(RequestError::Refused(format!(
348            "{address} is not a public address"
349        )))
350    }
351}
352
353fn permitted(address: IpAddr, allow: &[IpNet]) -> bool {
354    is_public(address) || allow.iter().any(|network| network.contains(&address))
355}
356
357/// Resolves names and drops every address the client refuses, so the
358/// connection is made only to an address that passed the check.
359struct GuardedResolver {
360    allow: Vec<IpNet>,
361}
362
363impl Resolve for GuardedResolver {
364    fn resolve(&self, name: Name) -> Resolving {
365        let allow = self.allow.clone();
366        Box::pin(async move {
367            let host = name.as_str().to_owned();
368            let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
369                .await?
370                .filter(|address| permitted(address.ip(), &allow))
371                .collect();
372            if addresses.is_empty() {
373                return Err(
374                    Box::new(Refused(format!("{host} resolves to no public address")))
375                        as Box<dyn std::error::Error + Send + Sync>,
376                );
377            }
378            Ok(Box::new(addresses.into_iter()) as Addrs)
379        })
380    }
381}
382
383/// Whether `address` is on the public internet.
384///
385/// Everything the IANA special-purpose registries set aside is refused, as
386/// Mastodon's `PrivateAddressCheck` refuses it. An IPv4-mapped IPv6 address
387/// is judged by the IPv4 address inside it, since that is where a packet to
388/// it ends up. The translation prefixes, NAT64 (`64:ff9b::/96`,
389/// `64:ff9b:1::/48`) and 6to4 (`2002::/16`), are refused whatever they
390/// carry, as Mastodon refuses them: a server behind NAT64 allows the prefix
391/// with [`ClientConfig::allow_private`], as a Mastodon server names it in
392/// `ALLOWED_PRIVATE_ADDRESSES`.
393#[must_use]
394pub fn is_public(address: IpAddr) -> bool {
395    match address {
396        IpAddr::V4(v4) => is_public_v4(v4),
397        IpAddr::V6(v6) => is_public_v6(v6),
398    }
399}
400
401fn is_public_v4(address: Ipv4Addr) -> bool {
402    let [a, b, c, _] = address.octets();
403    !(a == 0                                   // "this network"
404        || a == 10                             // private
405        || a == 127                            // loopback
406        || (a == 100 && (64..128).contains(&b)) // shared address space (CGNAT)
407        || (a == 169 && b == 254)              // link-local
408        || (a == 172 && (16..32).contains(&b)) // private
409        || (a == 192 && b == 0 && c == 0)      // IETF protocol assignments
410        || (a == 192 && b == 0 && c == 2)      // documentation
411        || (a == 192 && b == 88 && c == 99)    // 6to4 relay anycast
412        || (a == 192 && b == 168)              // private
413        || (a == 198 && (18..20).contains(&b)) // benchmarking
414        || (a == 198 && b == 51 && c == 100)   // documentation
415        || (a == 203 && b == 0 && c == 113)    // documentation
416        || a >= 224) // multicast, reserved, broadcast
417}
418
419fn is_public_v6(address: Ipv6Addr) -> bool {
420    let segments = address.segments();
421    if let Some(v4) = address.to_ipv4_mapped() {
422        return is_public_v4(v4);
423    }
424    !(address.is_unspecified()
425        || address.is_loopback()
426        || segments[0] & 0xfe00 == 0xfc00 // unique local
427        || segments[0] & 0xffc0 == 0xfe80 // link-local
428        || segments[0] & 0xffc0 == 0xfec0 // site-local, deprecated but routable by some
429        || segments[0] & 0xff00 == 0xff00 // multicast
430        || (segments[0] == 0x2001 && segments[1] == 0x0db8) // documentation
431        || (segments[0] == 0x2001 && segments[1] < 0x0200) // Teredo, benchmarking, ORCHID
432        || segments[..4] == [0x0100, 0, 0, 0] // discard-only
433        || segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] // NAT64
434        || segments[..3] == [0x64, 0xff9b, 1] // local-use NAT64
435        || segments[0] == 0x2002 // 6to4
436        || segments[0] & 0xfff0 == 0x3ff0 // documentation
437        || segments[..6] == [0, 0, 0, 0, 0, 0]) // IPv4-compatible, deprecated
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn public(address: &str) -> bool {
445        is_public(address.parse().expect("address"))
446    }
447
448    #[test]
449    fn public_addresses_are_permitted() {
450        for address in [
451            "1.1.1.1",
452            "8.8.8.8",
453            "93.184.216.34",
454            "2606:4700::1111",
455            "2a00:1450::1",
456        ] {
457            assert!(public(address), "{address}");
458        }
459    }
460
461    #[test]
462    fn reserved_addresses_are_refused() {
463        for address in [
464            "0.0.0.0",
465            "10.1.2.3",
466            "127.0.0.1",
467            "100.64.0.1",
468            "169.254.169.254",
469            "172.16.0.1",
470            "172.31.255.255",
471            "192.0.0.8",
472            "192.0.2.1",
473            "192.168.1.1",
474            "198.18.0.1",
475            "224.0.0.1",
476            "255.255.255.255",
477            "::",
478            "::1",
479            "fc00::1",
480            "fd12:3456::1",
481            "fe80::1",
482            "fec0::1",
483            "ff02::1",
484            "2001:db8::1",
485            "2001::1",
486            "100::1",
487        ] {
488            assert!(!public(address), "{address}");
489        }
490    }
491
492    /// An IPv6 address that reaches an IPv4 one is judged by that one.
493    #[test]
494    fn embedded_ipv4_addresses_are_judged_by_what_they_carry() {
495        assert!(!public("::ffff:127.0.0.1"));
496        assert!(!public("::ffff:10.0.0.1"));
497        assert!(public("::ffff:1.1.1.1"));
498        assert!(!public("::127.0.0.1")); // IPv4-compatible
499    }
500
501    /// Mastodon refuses the translation prefixes whatever they carry, and a
502    /// server behind NAT64 allows the prefix by name.
503    #[test]
504    fn translation_prefixes_are_refused_whatever_they_carry() {
505        for address in [
506            "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254
507            "64:ff9b::101:101",   // NAT64 of 1.1.1.1
508            "64:ff9b:1::101:101", // local-use NAT64
509            "2002:7f00:1::1",     // 6to4 of 127.0.0.1
510            "2002:101:101::1",    // 6to4 of 1.1.1.1
511            "3fff::1",            // documentation
512        ] {
513            assert!(!public(address), "{address}");
514        }
515        let allow: Vec<IpNet> = vec!["64:ff9b::/96".parse().expect("network")];
516        let url = Url::parse("http://[64:ff9b::101:101]/").expect("url");
517        assert!(validate_url(&url, &allow).is_ok());
518    }
519
520    #[test]
521    fn a_literal_address_in_a_url_is_checked() {
522        let allow = [];
523        for url in [
524            "http://127.0.0.1/inbox",
525            "https://[::1]/inbox",
526            "http://169.254.169.254/latest/meta-data",
527        ] {
528            let url = Url::parse(url).expect("url");
529            assert!(
530                matches!(validate_url(&url, &allow), Err(RequestError::Refused(_))),
531                "{url}"
532            );
533        }
534        assert!(matches!(
535            validate_url(&Url::parse("file:///etc/passwd").expect("url"), &allow),
536            Err(RequestError::InvalidUrl(_))
537        ));
538        assert!(validate_url(&Url::parse("https://example.com/").expect("url"), &allow).is_ok());
539    }
540
541    #[test]
542    fn an_allowed_network_is_reachable() {
543        let allow: Vec<IpNet> = vec!["127.0.0.0/8".parse().expect("network")];
544        let url = Url::parse("http://127.0.0.1:8080/inbox").expect("url");
545        assert!(validate_url(&url, &allow).is_ok());
546        let url = Url::parse("http://10.0.0.1/inbox").expect("url");
547        assert!(validate_url(&url, &allow).is_err());
548    }
549}