Skip to main content

actpub_nodeinfo/
client.rs

1//! Asynchronous `NodeInfo` client built on [`reqwest`].
2//!
3//! # Security considerations
4//!
5//! `NodeInfo` documents come from untrusted remote servers, so this
6//! client applies the same hardening as the sibling `WebFinger`
7//! client:
8//!
9//! - **Body size cap.** Responses are streamed and rejected with
10//!   [`Error::ResponseTooLarge`] once the accumulated bytes exceed
11//!   [`DEFAULT_MAX_BODY_BYTES`]. A well-formed discovery document or
12//!   `NodeInfo` payload is a few kilobytes at most; the 64 KiB default
13//!   accommodates unusually verbose extensions while foreclosing
14//!   out-of-memory `DoS`.
15//! - **Redirect policy.** [`recommended_client`] builds a
16//!   [`reqwest::Client`] that follows at most two redirects, all to
17//!   the same origin, matching Mastodon's defaults and neutralising
18//!   cross-origin redirect attacks on the endpoint.
19
20use reqwest::redirect::Policy;
21use reqwest::{Client, ClientBuilder, header};
22use tracing::debug;
23use url::Url;
24
25use crate::{Discovery, Error, NodeInfo, Version};
26
27/// Default hard cap on the response body we will read from a
28/// `NodeInfo` endpoint.
29pub const DEFAULT_MAX_BODY_BYTES: u64 = 64 * 1024;
30
31/// Builds a [`reqwest::Client`] pre-configured for safe `NodeInfo`
32/// resolution.
33///
34/// The returned client uses a strict redirect policy — at most two
35/// redirects, all to the same origin — which matches Mastodon's
36/// defaults and neutralises cross-origin redirect attacks on the
37/// endpoint.
38///
39/// # Errors
40///
41/// Returns [`Error::Http`] if the underlying TLS stack cannot be
42/// initialised.
43pub fn recommended_client() -> Result<Client, Error> {
44    Ok(ClientBuilder::new()
45        .redirect(Policy::custom(|attempt| {
46            const MAX_REDIRECTS: usize = 2;
47            if attempt.previous().len() >= MAX_REDIRECTS {
48                return attempt.error("too many redirects");
49            }
50            let origin = attempt.previous().first().unwrap_or_else(|| attempt.url());
51            if origin.host_str() == attempt.url().host_str()
52                && origin.scheme() == attempt.url().scheme()
53            {
54                attempt.follow()
55            } else {
56                attempt.error("cross-origin redirect forbidden for NodeInfo")
57            }
58        }))
59        .build()?)
60}
61
62/// Fetches the `/.well-known/nodeinfo` discovery document from `host`,
63/// enforcing [`DEFAULT_MAX_BODY_BYTES`] as the body size cap.
64///
65/// `host` should be a full base URL (including scheme), e.g.
66/// `https://mastodon.social`. Pass a client obtained from
67/// [`recommended_client`] to also benefit from the same-origin
68/// redirect policy.
69///
70/// # Errors
71///
72/// Returns [`Error::InvalidUrl`] if `host` cannot be joined with the
73/// well-known path, [`Error::Http`] for network failures,
74/// [`Error::BadStatus`] for non-2xx responses,
75/// [`Error::ResponseTooLarge`] when the body exceeds the cap, and
76/// [`Error::Json`] if the body is not valid JSON.
77pub async fn fetch_discovery(host: &Url, client: &Client) -> Result<Discovery, Error> {
78    fetch_discovery_with_limit(host, client, DEFAULT_MAX_BODY_BYTES).await
79}
80
81/// [`fetch_discovery`] variant accepting an explicit body size cap.
82///
83/// # Errors
84///
85/// Same as [`fetch_discovery`].
86pub async fn fetch_discovery_with_limit(
87    host: &Url,
88    client: &Client,
89    max_body_bytes: u64,
90) -> Result<Discovery, Error> {
91    let url = host.join(crate::WELL_KNOWN_PATH)?;
92    debug!(%url, max_body_bytes, "fetching NodeInfo discovery");
93
94    let body = request_capped(client, url, max_body_bytes).await?;
95    Ok(serde_json::from_slice(&body)?)
96}
97
98/// Fetches a [`NodeInfo`] document at `version` from `host`.
99///
100/// Resolves the concrete schema URL via the discovery document and
101/// then fetches and parses the `NodeInfo` JSON, enforcing
102/// [`DEFAULT_MAX_BODY_BYTES`] on both requests.
103///
104/// # Errors
105///
106/// Returns [`Error::VersionNotAdvertised`] if the server does not
107/// advertise the requested version, and propagates transport / parse
108/// errors via [`Error::Http`] / [`Error::Json`] / [`Error::BadStatus`]
109/// / [`Error::ResponseTooLarge`].
110pub async fn fetch(host: &Url, version: Version, client: &Client) -> Result<NodeInfo, Error> {
111    fetch_with_limit(host, version, client, DEFAULT_MAX_BODY_BYTES).await
112}
113
114/// [`fetch`] variant accepting an explicit body size cap.
115///
116/// # Errors
117///
118/// Same as [`fetch`].
119pub async fn fetch_with_limit(
120    host: &Url,
121    version: Version,
122    client: &Client,
123    max_body_bytes: u64,
124) -> Result<NodeInfo, Error> {
125    let discovery = fetch_discovery_with_limit(host, client, max_body_bytes).await?;
126    let link = discovery
127        .find_link(version)
128        .ok_or(Error::VersionNotAdvertised {
129            requested: version.as_str(),
130        })?;
131
132    debug!(url = %link.href, max_body_bytes, "fetching NodeInfo document");
133
134    let body = request_capped(client, link.href.clone(), max_body_bytes).await?;
135    Ok(serde_json::from_slice(&body)?)
136}
137
138async fn request_capped(client: &Client, url: Url, max_body_bytes: u64) -> Result<Vec<u8>, Error> {
139    let response = client
140        .get(url)
141        .header(header::ACCEPT, "application/json")
142        .send()
143        .await?;
144
145    let status = response.status();
146    if !status.is_success() {
147        return Err(Error::BadStatus(status.as_u16()));
148    }
149
150    read_capped(response, max_body_bytes).await
151}
152
153async fn read_capped(
154    mut response: reqwest::Response,
155    max_body_bytes: u64,
156) -> Result<Vec<u8>, Error> {
157    let mut acc: Vec<u8> = Vec::new();
158    while let Some(chunk) = response.chunk().await? {
159        if max_body_bytes > 0 && (acc.len() as u64 + chunk.len() as u64) > max_body_bytes {
160            return Err(Error::ResponseTooLarge(max_body_bytes));
161        }
162        acc.extend_from_slice(&chunk);
163    }
164    Ok(acc)
165}
166
167#[cfg(test)]
168mod tests {
169    use pretty_assertions::assert_eq;
170    use serde_json::json;
171    use wiremock::matchers::{method, path};
172    use wiremock::{Mock, MockServer, ResponseTemplate};
173
174    use super::*;
175
176    #[tokio::test]
177    async fn end_to_end_fetch_via_mock_server() {
178        let server = MockServer::start().await;
179        let base: Url = server.uri().parse().unwrap();
180
181        let nodeinfo_url = format!("{base}nodeinfo/2.1");
182
183        Mock::given(method("GET"))
184            .and(path("/.well-known/nodeinfo"))
185            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
186                "links": [{
187                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
188                    "href": nodeinfo_url
189                }]
190            })))
191            .mount(&server)
192            .await;
193
194        Mock::given(method("GET"))
195            .and(path("/nodeinfo/2.1"))
196            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
197                "version": "2.1",
198                "software": { "name": "mock-server", "version": "0.1.0" },
199                "protocols": ["activitypub"],
200                "openRegistrations": false,
201                "usage": {}
202            })))
203            .mount(&server)
204            .await;
205
206        let client = Client::new();
207        let info = fetch(&base, Version::V2_1, &client).await.unwrap();
208
209        assert_eq!(info.version, Version::V2_1);
210        assert_eq!(info.software.name, "mock-server");
211    }
212
213    #[tokio::test]
214    async fn oversized_discovery_body_is_rejected() {
215        let server = MockServer::start().await;
216        let base: Url = server.uri().parse().unwrap();
217
218        // 128 KiB body — comfortably above the 64 KiB default cap.
219        let padding = "x".repeat(128 * 1024);
220        Mock::given(method("GET"))
221            .and(path("/.well-known/nodeinfo"))
222            .respond_with(ResponseTemplate::new(200).set_body_raw(
223                format!(r#"{{"links":[],"padding":"{padding}"}}"#).into_bytes(),
224                "application/json",
225            ))
226            .mount(&server)
227            .await;
228
229        let client = Client::new();
230        let err = fetch_discovery(&base, &client)
231            .await
232            .expect_err("oversized body must be rejected");
233        assert!(matches!(
234            err,
235            Error::ResponseTooLarge(DEFAULT_MAX_BODY_BYTES)
236        ));
237    }
238
239    #[tokio::test]
240    async fn recommended_client_rejects_cross_origin_redirect() {
241        let primary = MockServer::start().await;
242        let attacker = MockServer::start().await;
243
244        // Attacker pretends to serve a well-known NodeInfo discovery
245        // but redirects off-origin. The recommended client's policy
246        // must refuse to follow that hop.
247        Mock::given(method("GET"))
248            .and(path("/.well-known/nodeinfo"))
249            .respond_with(ResponseTemplate::new(302).insert_header(
250                "Location",
251                format!("{}/.well-known/nodeinfo", attacker.uri()),
252            ))
253            .mount(&primary)
254            .await;
255
256        let client = recommended_client().expect("client builds");
257        let base: Url = primary.uri().parse().unwrap();
258        // The exact error surface depends on the redirect-policy
259        // message plumbing; the important invariant is that the
260        // fetch does *not* succeed in following the off-origin hop.
261        fetch_discovery(&base, &client)
262            .await
263            .expect_err("cross-origin redirect must fail");
264    }
265
266    #[tokio::test]
267    async fn missing_version_returns_specific_error() {
268        let server = MockServer::start().await;
269        let base: Url = server.uri().parse().unwrap();
270
271        Mock::given(method("GET"))
272            .and(path("/.well-known/nodeinfo"))
273            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
274                "links": [{
275                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
276                    "href": format!("{base}nodeinfo/2.0")
277                }]
278            })))
279            .mount(&server)
280            .await;
281
282        let client = Client::new();
283        let err = fetch(&base, Version::V2_1, &client).await.unwrap_err();
284        assert!(matches!(
285            err,
286            Error::VersionNotAdvertised { requested: "2.1" }
287        ));
288    }
289}