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    // SSRF hardening (seventh-round audit P1-10): the advertised
133    // `href` comes from an untrusted remote document, so an
134    // attacker serving `/.well-known/nodeinfo` can redirect the
135    // client to an arbitrary URL — classically a cloud-metadata
136    // endpoint (`http://169.254.169.254/…`) or a loopback /
137    // private-range target — before the redirect-policy ever
138    // gets a chance to run, because it fires only on 3xx hops
139    // within a single `client.get(…)` call. We therefore refuse
140    // any href whose *origin* (scheme + host + port) differs
141    // from the discovery origin we were told to talk to.
142    if !same_origin(host, &link.href) {
143        return Err(Error::CrossOriginHref {
144            discovery: host.clone(),
145            href: link.href.clone(),
146        });
147    }
148
149    debug!(url = %link.href, max_body_bytes, "fetching NodeInfo document");
150
151    let body = request_capped(client, link.href.clone(), max_body_bytes).await?;
152    Ok(serde_json::from_slice(&body)?)
153}
154
155/// Returns `true` iff `a` and `b` share scheme, host and effective
156/// port. Matches the same-origin semantics Fetch / CORS use, and
157/// is the narrowest definition that still lets a server legally
158/// advertise `http://host/` during discovery and
159/// `http://host/nodeinfo/2.1` for the document.
160fn same_origin(a: &Url, b: &Url) -> bool {
161    a.scheme().eq_ignore_ascii_case(b.scheme())
162        && match (a.host_str(), b.host_str()) {
163            (Some(ha), Some(hb)) => ha.eq_ignore_ascii_case(hb),
164            _ => false,
165        }
166        && a.port_or_known_default() == b.port_or_known_default()
167}
168
169async fn request_capped(client: &Client, url: Url, max_body_bytes: u64) -> Result<Vec<u8>, Error> {
170    let response = client
171        .get(url)
172        .header(header::ACCEPT, "application/json")
173        .send()
174        .await?;
175
176    let status = response.status();
177    if !status.is_success() {
178        return Err(Error::BadStatus(status.as_u16()));
179    }
180
181    read_capped(response, max_body_bytes).await
182}
183
184async fn read_capped(
185    mut response: reqwest::Response,
186    max_body_bytes: u64,
187) -> Result<Vec<u8>, Error> {
188    let mut acc: Vec<u8> = Vec::new();
189    while let Some(chunk) = response.chunk().await? {
190        if max_body_bytes > 0 && (acc.len() as u64 + chunk.len() as u64) > max_body_bytes {
191            return Err(Error::ResponseTooLarge(max_body_bytes));
192        }
193        acc.extend_from_slice(&chunk);
194    }
195    Ok(acc)
196}
197
198#[cfg(test)]
199mod tests {
200    use pretty_assertions::assert_eq;
201    use serde_json::json;
202    use wiremock::matchers::{method, path};
203    use wiremock::{Mock, MockServer, ResponseTemplate};
204
205    use super::*;
206
207    #[tokio::test]
208    async fn end_to_end_fetch_via_mock_server() {
209        let server = MockServer::start().await;
210        let base: Url = server.uri().parse().unwrap();
211
212        let nodeinfo_url = format!("{base}nodeinfo/2.1");
213
214        Mock::given(method("GET"))
215            .and(path("/.well-known/nodeinfo"))
216            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
217                "links": [{
218                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
219                    "href": nodeinfo_url
220                }]
221            })))
222            .mount(&server)
223            .await;
224
225        Mock::given(method("GET"))
226            .and(path("/nodeinfo/2.1"))
227            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
228                "version": "2.1",
229                "software": { "name": "mock-server", "version": "0.1.0" },
230                "protocols": ["activitypub"],
231                "openRegistrations": false,
232                "usage": {}
233            })))
234            .mount(&server)
235            .await;
236
237        let client = Client::new();
238        let info = fetch(&base, Version::V2_1, &client).await.unwrap();
239
240        assert_eq!(info.version, Version::V2_1);
241        assert_eq!(info.software.name, "mock-server");
242    }
243
244    #[tokio::test]
245    async fn fetch_refuses_cross_origin_href_advertised_by_discovery() {
246        // P1-10 (seventh-round audit) regression: an attacker
247        // controlling `/.well-known/nodeinfo` at the legitimate
248        // host could advertise a `href` pointing ANYWHERE — a
249        // cloud-metadata endpoint, a loopback admin interface, a
250        // private-network target — because the default `reqwest`
251        // redirect policy only kicks in on 3xx hops, not on the
252        // initial `client.get(href)` call. `fetch_with_limit`
253        // MUST refuse any href whose origin (scheme + host + port)
254        // does not match the discovery host.
255        let primary = MockServer::start().await;
256        let attacker = MockServer::start().await;
257        let attacker_nodeinfo = format!("{}/nodeinfo/2.1", attacker.uri());
258        Mock::given(method("GET"))
259            .and(path("/.well-known/nodeinfo"))
260            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
261                "links": [{
262                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
263                    "href": attacker_nodeinfo
264                }]
265            })))
266            .mount(&primary)
267            .await;
268
269        let client = Client::new();
270        let base: Url = primary.uri().parse().unwrap();
271        let err = fetch(&base, Version::V2_1, &client)
272            .await
273            .expect_err("cross-origin href must be refused");
274        assert!(
275            matches!(err, Error::CrossOriginHref { .. }),
276            "expected CrossOriginHref, got {err:?}",
277        );
278    }
279
280    #[tokio::test]
281    async fn oversized_discovery_body_is_rejected() {
282        let server = MockServer::start().await;
283        let base: Url = server.uri().parse().unwrap();
284
285        // 128 KiB body — comfortably above the 64 KiB default cap.
286        let padding = "x".repeat(128 * 1024);
287        Mock::given(method("GET"))
288            .and(path("/.well-known/nodeinfo"))
289            .respond_with(ResponseTemplate::new(200).set_body_raw(
290                format!(r#"{{"links":[],"padding":"{padding}"}}"#).into_bytes(),
291                "application/json",
292            ))
293            .mount(&server)
294            .await;
295
296        let client = Client::new();
297        let err = fetch_discovery(&base, &client)
298            .await
299            .expect_err("oversized body must be rejected");
300        assert!(matches!(
301            err,
302            Error::ResponseTooLarge(DEFAULT_MAX_BODY_BYTES)
303        ));
304    }
305
306    #[tokio::test]
307    async fn recommended_client_rejects_cross_origin_redirect() {
308        let primary = MockServer::start().await;
309        let attacker = MockServer::start().await;
310
311        // Attacker pretends to serve a well-known NodeInfo discovery
312        // but redirects off-origin. The recommended client's policy
313        // must refuse to follow that hop.
314        Mock::given(method("GET"))
315            .and(path("/.well-known/nodeinfo"))
316            .respond_with(ResponseTemplate::new(302).insert_header(
317                "Location",
318                format!("{}/.well-known/nodeinfo", attacker.uri()),
319            ))
320            .mount(&primary)
321            .await;
322
323        let client = recommended_client().expect("client builds");
324        let base: Url = primary.uri().parse().unwrap();
325        // The exact error surface depends on the redirect-policy
326        // message plumbing; the important invariant is that the
327        // fetch does *not* succeed in following the off-origin hop.
328        fetch_discovery(&base, &client)
329            .await
330            .expect_err("cross-origin redirect must fail");
331    }
332
333    #[tokio::test]
334    async fn missing_version_returns_specific_error() {
335        let server = MockServer::start().await;
336        let base: Url = server.uri().parse().unwrap();
337
338        Mock::given(method("GET"))
339            .and(path("/.well-known/nodeinfo"))
340            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
341                "links": [{
342                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
343                    "href": format!("{base}nodeinfo/2.0")
344                }]
345            })))
346            .mount(&server)
347            .await;
348
349        let client = Client::new();
350        let err = fetch(&base, Version::V2_1, &client).await.unwrap_err();
351        assert!(matches!(
352            err,
353            Error::VersionNotAdvertised { requested: "2.1" }
354        ));
355    }
356}