Skip to main content

hashiverse_server_lib/tools/
tools.rs

1//! # Server-only helpers
2//!
3//! A small grab bag of utilities the server needs that don't belong in
4//! `hashiverse-lib`:
5//!
6//! - [`get_public_ipv4`] — asks ipify.org for the node's externally-visible IPv4 so
7//!   the node's advertised peer address matches reality (behind NAT / firewalls).
8//!   Falls back to loopback when explicitly running in a local-only test network.
9//! - [`is_ssrf_protected_ip`] — the deny-list for every server-initiated outbound
10//!   fetch (URL previews, Let's Encrypt challenges, ipify). Rejects loopback,
11//!   RFC-1918 private ranges, link-local, CGNAT, cloud-metadata endpoints
12//!   (169.254.169.254 friends), multicast, broadcast, the unspecified address,
13//!   IPv6 ULA/link-local, and IPv4-mapped IPv6. This is the primary defence against
14//!   SSRF — a malicious URL in a post must not let a client coerce our server into
15//!   probing its own internal network.
16//! - [`spawn_ctrl_c_handler`] — wires `ctrl-c` into a `CancellationToken` so the
17//!   server can shut down gracefully.
18
19use log::warn;
20use tokio_util::sync::CancellationToken;
21
22pub async fn get_public_ipv4(force_local_network: bool) -> anyhow::Result<String> {
23    match force_local_network {
24        true => Ok("127.0.0.1".to_string()),
25        false => {
26            let client = reqwest::ClientBuilder::new().danger_accept_invalid_certs(true).build()?; // we are willing to accept invalid certs as we do application level signature checking
27            let response = client.get("https://api4.ipify.org").send().await?.text().await?;
28            Ok(response)
29        }
30    }
31}
32
33pub fn spawn_ctrl_c_handler(cancellation_token: CancellationToken) {
34    tokio::spawn(async move {
35        if tokio::signal::ctrl_c().await.is_ok() {
36            warn!("Ctrl+C received, cancelling...");
37            cancellation_token.cancel();
38        }
39    });
40}
41
42/// Returns true for any IP that must never be the target of a server-initiated fetch.
43/// Covers: loopback, RFC-1918 private, link-local (incl. cloud metadata 169.254.169.254),
44/// CGNAT (100.64.0.0/10), multicast, unspecified, broadcast, IPv6 ULA (fc00::/7),
45/// IPv6 link-local (fe80::/10), and IPv4-mapped IPv6 addresses whose embedded v4 is protected.
46pub fn is_ssrf_protected_ip(ip: std::net::IpAddr) -> bool {
47    match ip {
48        std::net::IpAddr::V4(v4) => {
49            let o = v4.octets();
50            v4.is_loopback()                               // 127.0.0.0/8
51                || v4.is_private()                         // 10/8, 172.16/12, 192.168/16
52                || v4.is_link_local()                      // 169.254.0.0/16 — cloud metadata
53                || v4.is_multicast()                       // 224.0.0.0/4
54                || v4.is_unspecified()                     // 0.0.0.0
55                || v4.is_broadcast()                       // 255.255.255.255
56                || (o[0] == 100 && (o[1] & 0xC0) == 64)   // CGNAT 100.64.0.0/10
57        }
58        std::net::IpAddr::V6(v6) => {
59            let s = v6.segments();
60            v6.is_loopback()                               // ::1
61                || v6.is_multicast()                       // ff00::/8
62                || v6.is_unspecified()                     // ::
63                || (s[0] & 0xFFC0) == 0xFE80              // link-local fe80::/10
64                || (s[0] & 0xFE00) == 0xFC00              // unique local fc00::/7
65                // IPv4-mapped (::ffff:0:0/96) — recurse on the embedded v4 address
66                || matches!(v6.to_ipv4_mapped(), Some(v4) if is_ssrf_protected_ip(std::net::IpAddr::V4(v4)))
67        }
68    }
69}