Skip to main content

cairn_mod/auth/
ssrf.rs

1//! SSRF protection for outbound DID resolution (did:web).
2//!
3//! Without in-process filtering, a malicious `did:web:<host>` whose DNS
4//! resolves to a private, loopback, or link-local address could read
5//! from the metadata endpoint at `169.254.169.254` or from internal
6//! infrastructure. Most operator hardening stops there at the network
7//! layer, but Cairn is distributed as a crate — other operators install
8//! it in environments where outbound filtering may be absent or
9//! incomplete. The defense belongs in the library.
10//!
11//! Strategy: plug into `reqwest::dns::Resolve` so the check runs during
12//! the request's own DNS resolution phase. Every IP returned by the
13//! system resolver is checked against the block list; if none pass, the
14//! request fails before a socket is opened.
15//!
16//! This also prevents the TOCTOU variant where an attacker's DNS
17//! returns two records (one safe, one internal) and the HTTP client
18//! picks the wrong one — only safe IPs reach the connection attempt.
19
20use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
21use std::sync::Arc;
22
23use reqwest::dns::{Addrs, Name, Resolve, Resolving};
24
25/// Reject rules for outbound addresses reached via DNS resolution.
26///
27/// Per §5 threat model, did:web hosts that resolve to any of these
28/// categories must fail closed; a legitimate public DID never points at
29/// one of these ranges.
30#[must_use]
31pub fn is_blocked_ip(ip: IpAddr) -> bool {
32    match ip {
33        IpAddr::V4(v4) => is_blocked_ipv4(v4),
34        IpAddr::V6(v6) => {
35            // IPv4-mapped IPv6 (::ffff:a.b.c.d): apply v4 rules to the
36            // mapped address. Attackers can encode a private v4 this way.
37            if let Some(v4) = v6.to_ipv4_mapped() {
38                return is_blocked_ipv4(v4);
39            }
40            is_blocked_ipv6(v6)
41        }
42    }
43}
44
45fn is_blocked_ipv4(v4: Ipv4Addr) -> bool {
46    v4.is_loopback()              // 127.0.0.0/8
47        || v4.is_private()        // 10/8, 172.16/12, 192.168/16
48        || v4.is_link_local()     // 169.254.0.0/16 — includes cloud metadata (169.254.169.254)
49        || v4.is_broadcast()      // 255.255.255.255
50        || v4.is_documentation()  // 192.0.2, 198.51.100, 203.0.113 — non-routable, could be proxied
51        || v4.is_unspecified()    // 0.0.0.0
52        || v4.is_multicast()      // 224.0.0.0/4
53        || is_cgnat(v4) // 100.64.0.0/10 — carrier-grade NAT, not globally routable
54}
55
56/// Carrier-grade NAT range (RFC 6598). Rust's `Ipv4Addr::is_shared` is
57/// stable as of 1.76 but exposed only under the unstable feature
58/// `ip_extra_stabilized` on some toolchains; the check is two bytes so
59/// inlining is fine.
60fn is_cgnat(v4: Ipv4Addr) -> bool {
61    let [a, b, _, _] = v4.octets();
62    a == 100 && (64..=127).contains(&b)
63}
64
65fn is_blocked_ipv6(v6: Ipv6Addr) -> bool {
66    v6.is_loopback()                          // ::1
67        || v6.is_multicast()                  // ff00::/8
68        || v6.is_unspecified()                // ::
69        || is_ipv6_link_local(v6)             // fe80::/10
70        || is_ipv6_unique_local(v6) // fc00::/7 (ULA)
71}
72
73fn is_ipv6_link_local(v6: Ipv6Addr) -> bool {
74    // fe80::/10 — first 10 bits are 1111111010.
75    (v6.segments()[0] & 0xffc0) == 0xfe80
76}
77
78fn is_ipv6_unique_local(v6: Ipv6Addr) -> bool {
79    // fc00::/7 — first 7 bits are 1111110.
80    (v6.segments()[0] & 0xfe00) == 0xfc00
81}
82
83/// Error surfaced when every resolved IP for a host is blocked. Kept as
84/// a concrete type so the caller can map it to an auth-boundary-friendly
85/// generic error without leaking "this host is private" back to clients.
86#[derive(Debug, thiserror::Error)]
87#[error("SSRF protection rejected all IPs for host: {host}")]
88pub struct SsrfRejected {
89    /// Host (DNS name) whose resolved IPs were all blocked.
90    pub host: String,
91}
92
93/// `reqwest::dns::Resolve` wrapper that runs the system resolver (via
94/// `tokio::net::lookup_host`) and filters results through [`is_blocked_ip`].
95///
96/// Returned as `Arc<dyn Resolve>` to satisfy reqwest's builder.
97#[derive(Debug, Default)]
98pub struct SafeDnsResolver;
99
100impl Resolve for SafeDnsResolver {
101    fn resolve(&self, name: Name) -> Resolving {
102        let host = name.as_str().to_owned();
103        Box::pin(async move {
104            // `lookup_host` needs a `host:port`. Port doesn't matter for
105            // resolution itself; reqwest's internal caller overrides it.
106            let addrs = tokio::net::lookup_host(format!("{host}:0"))
107                .await
108                .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
109
110            let safe: Vec<SocketAddr> = addrs.filter(|sa| !is_blocked_ip(sa.ip())).collect();
111            if safe.is_empty() {
112                return Err(
113                    Box::new(SsrfRejected { host }) as Box<dyn std::error::Error + Send + Sync>
114                );
115            }
116            Ok(Box::new(safe.into_iter()) as Addrs)
117        })
118    }
119}
120
121impl SafeDnsResolver {
122    /// Convenience for `ClientBuilder::dns_resolver` which expects an
123    /// `Arc<R>` with `R: Resolve + Sized`. Returning `Arc<Self>` rather
124    /// than `Arc<dyn Resolve>` satisfies the bound.
125    pub fn arc() -> Arc<Self> {
126        Arc::new(Self)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn v4(s: &str) -> IpAddr {
135        IpAddr::V4(s.parse().unwrap())
136    }
137    fn v6(s: &str) -> IpAddr {
138        IpAddr::V6(s.parse().unwrap())
139    }
140
141    #[test]
142    fn blocks_loopback_v4() {
143        assert!(is_blocked_ip(v4("127.0.0.1")));
144        assert!(is_blocked_ip(v4("127.255.255.255")));
145    }
146
147    #[test]
148    fn blocks_private_ranges() {
149        assert!(is_blocked_ip(v4("10.0.0.1")));
150        assert!(is_blocked_ip(v4("10.255.255.255")));
151        assert!(is_blocked_ip(v4("172.16.0.1")));
152        assert!(is_blocked_ip(v4("172.31.255.255")));
153        assert!(is_blocked_ip(v4("192.168.1.1")));
154    }
155
156    #[test]
157    fn blocks_link_local_including_metadata() {
158        // The specific cloud metadata endpoint that motivates SSRF filtering.
159        assert!(is_blocked_ip(v4("169.254.169.254")));
160        // Whole link-local range.
161        assert!(is_blocked_ip(v4("169.254.0.1")));
162        assert!(is_blocked_ip(v4("169.254.255.254")));
163    }
164
165    #[test]
166    fn blocks_cgnat() {
167        assert!(is_blocked_ip(v4("100.64.0.1")));
168        assert!(is_blocked_ip(v4("100.127.255.254")));
169    }
170
171    #[test]
172    fn blocks_broadcast_and_unspecified_and_multicast() {
173        assert!(is_blocked_ip(v4("0.0.0.0")));
174        assert!(is_blocked_ip(v4("255.255.255.255")));
175        assert!(is_blocked_ip(v4("224.0.0.1")));
176    }
177
178    #[test]
179    fn blocks_ipv6_loopback_link_local_ula_multicast_unspecified() {
180        assert!(is_blocked_ip(v6("::1")));
181        assert!(is_blocked_ip(v6("::")));
182        assert!(is_blocked_ip(v6("fe80::1")));
183        assert!(is_blocked_ip(v6("fc00::1"))); // ULA
184        assert!(is_blocked_ip(v6("fd00::1"))); // ULA
185        assert!(is_blocked_ip(v6("ff02::1"))); // Multicast
186    }
187
188    #[test]
189    fn blocks_ipv4_mapped_v6() {
190        // ::ffff:10.0.0.1 — attacker tries to encode private v4 in v6.
191        assert!(is_blocked_ip(v6("::ffff:10.0.0.1")));
192        assert!(is_blocked_ip(v6("::ffff:169.254.169.254")));
193        assert!(is_blocked_ip(v6("::ffff:127.0.0.1")));
194    }
195
196    #[test]
197    fn allows_public_addresses() {
198        // Representative public IPs.
199        assert!(!is_blocked_ip(v4("1.1.1.1")));
200        assert!(!is_blocked_ip(v4("8.8.8.8")));
201        assert!(!is_blocked_ip(v4("140.82.121.3")));
202        // Global-scope IPv6.
203        assert!(!is_blocked_ip(v6("2606:4700:4700::1111"))); // Cloudflare
204        assert!(!is_blocked_ip(v6("2001:4860:4860::8888"))); // Google
205    }
206}