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}