Skip to main content

entelix_tools/
dns.rs

1//! SSRF-safe DNS resolver for `HttpFetchTool`.
2//!
3//! Without an explicit resolver, `reqwest`'s default DNS path runs at
4//! connect time — *after* the [`HostAllowlist`](crate::HostAllowlist)
5//! check on the URL host string has already passed. That leaves a
6//! TOCTOU window: a hostname admitted by the allowlist can resolve to
7//! a private / loopback / cloud-metadata IP at connect time, and the
8//! tool happily reaches into the cluster's blast radius.
9//!
10//! [`SsrfSafeDnsResolver`] closes that window. It plugs into the
11//! reqwest client via [`reqwest::dns::Resolve`]; every hostname the
12//! HTTP stack tries to connect to passes through us first. We
13//! resolve via `hickory-resolver` (modern async DNS, not the
14//! blocking `std::net::ToSocketAddrs` path), filter out IPs in the
15//! configured block ranges, and hand reqwest only the survivors.
16//! When no IP survives, the request fails *before* a connection is
17//! attempted.
18//!
19//! ## What's blocked by default
20//!
21//! - IPv4 loopback (`127.0.0.0/8`)
22//! - IPv4 private (RFC 1918: `10/8`, `172.16/12`, `192.168/16`)
23//! - IPv4 link-local (`169.254/16`) — covers AWS / GCP / Azure
24//!   metadata endpoints (`169.254.169.254`).
25//! - IPv4 CGNAT (`100.64/10`)
26//! - IPv4 unspecified / broadcast / multicast / documentation ranges
27//! - IPv6 loopback (`::1`), unspecified (`::`), unique-local
28//!   (`fc00::/7`), link-local (`fe80::/10`), multicast (`ff00::/8`)
29//!
30//! ## Override path
31//!
32//! Operators sometimes need a private IP (e.g. `127.0.0.1:8080` for
33//! an on-prem inference proxy). They register that IP via
34//! [`HostAllowlist::allow_ip_exact`](crate::HostAllowlist::allow_ip_exact);
35//! [`HttpFetchToolBuilder`](crate::HttpFetchToolBuilder) plumbs the
36//! set into the resolver, which lets matching IPs through even when
37//! the default block would reject them.
38
39use std::collections::HashSet;
40use std::net::{IpAddr, SocketAddr};
41use std::sync::Arc;
42
43use hickory_resolver::TokioResolver;
44use hickory_resolver::config::{ResolverConfig, ResolverOpts};
45use reqwest::dns::{Addrs, Name, Resolve, Resolving};
46
47use crate::error::ToolError;
48
49/// Returns `true` for IPs the SDK refuses to connect to by default.
50///
51/// Call out via the public re-export only if you're building a
52/// custom resolver — `HttpFetchTool` already wires this through
53/// [`SsrfSafeDnsResolver`].
54///
55/// ## IPv6 tunnel coverage
56///
57/// A naïve "block IPv4 private ranges" pass leaks: an attacker can
58/// embed a private IPv4 inside an IPv6 prefix and bypass the v4
59/// check entirely. We extract the embedded IPv4 from the relevant
60/// prefixes and recurse, so:
61///
62/// - `::ffff:127.0.0.1` (IPv4-mapped) routes through the v4 block.
63/// - `2002:7f00:0001::` (6to4 — `2002:WWXX:YYZZ::/48` carries IPv4
64///   `WW.XX.YY.ZZ`) routes through the v4 block.
65/// - `2001:0:WWXX:YYZZ::` (Teredo — server-mapped IPv4 in segs[2..4])
66///   routes through the v4 block.
67///
68/// In addition, blanket Teredo (`2001::/32`) and 6to4 (`2002::/16`)
69/// prefixes are themselves blocked by default, since they are
70/// rarely the right transport for outbound LLM tool calls.
71#[must_use]
72pub fn is_ssrf_blocked(ip: &IpAddr) -> bool {
73    match ip {
74        IpAddr::V4(v4) => is_ssrf_blocked_v4(*v4),
75        IpAddr::V6(v6) => is_ssrf_blocked_v6(*v6),
76    }
77}
78
79fn is_ssrf_blocked_v4(v4: std::net::Ipv4Addr) -> bool {
80    let octets = v4.octets();
81    v4.is_loopback()
82        || v4.is_private()
83        || v4.is_link_local()
84        || v4.is_broadcast()
85        || v4.is_unspecified()
86        || v4.is_multicast()
87        || v4.is_documentation()
88        // CGNAT 100.64.0.0/10 (RFC 6598). Not flagged by
89        // `is_private` but still routes only inside the carrier.
90        || (octets[0] == 100 && (64..=127).contains(&octets[1]))
91}
92
93fn is_ssrf_blocked_v6(v6: std::net::Ipv6Addr) -> bool {
94    let segs = v6.segments();
95    if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() {
96        return true;
97    }
98    // Unique-local (fc00::/7).
99    if segs[0] & 0xfe00 == 0xfc00 {
100        return true;
101    }
102    // Link-local (fe80::/10).
103    if segs[0] & 0xffc0 == 0xfe80 {
104        return true;
105    }
106    // IPv4-mapped IPv6 (::ffff:0:0/96): `::ffff:W.X.Y.Z` is the same
107    // host as the v4 address — apply the v4 block to the embedded
108    // octets, otherwise an attacker resolves to ::ffff:127.0.0.1
109    // and tunnels into loopback unchecked.
110    if segs[0..5].iter().all(|s| *s == 0) && segs[5] == 0xffff {
111        let v4 = std::net::Ipv4Addr::new(
112            (segs[6] >> 8) as u8,
113            (segs[6] & 0xff) as u8,
114            (segs[7] >> 8) as u8,
115            (segs[7] & 0xff) as u8,
116        );
117        return is_ssrf_blocked_v4(v4);
118    }
119    // 6to4 (2002::/16) — `2002:WWXX:YYZZ::/48` encapsulates IPv4
120    // `WW.XX.YY.ZZ`. Block the entire /16 unconditionally:
121    // 6to4 has no legitimate role in an outbound LLM call, and
122    // the embedded v4 is meaningless when the prefix is rejected.
123    if segs[0] == 0x2002 {
124        return true;
125    }
126    // Teredo (2001::/32) — `2001:0:SVR_IP4_HI:SVR_IP4_LO::` carries
127    // the Teredo server's IPv4 in segs[2..4]. Block the entire
128    // /32 unconditionally — Teredo is a NAT-traversal protocol
129    // that has no legitimate role in an outbound LLM call.
130    if segs[0] == 0x2001 && segs[1] == 0 {
131        return true;
132    }
133    false
134}
135
136/// `reqwest::dns::Resolve` impl that vets every resolved IP against
137/// [`is_ssrf_blocked`] before handing addresses back to the HTTP
138/// connector. See module docs for the threat model.
139pub struct SsrfSafeDnsResolver {
140    inner: TokioResolver,
141    /// IP literals registered via
142    /// [`HostAllowlist::allow_ip_exact`](crate::HostAllowlist::allow_ip_exact).
143    /// These bypass the default block so on-prem allowances still
144    /// work.
145    explicit_allow: Arc<HashSet<IpAddr>>,
146}
147
148impl SsrfSafeDnsResolver {
149    /// Build a resolver from the system's DNS config (`/etc/resolv.conf`
150    /// on Unix, the Windows registry equivalents otherwise) with no
151    /// explicit IP overrides.
152    pub fn from_system() -> Result<Self, ToolError> {
153        let inner = TokioResolver::builder_tokio()
154            .map_err(|e| ToolError::Config {
155                message: format!("DNS: failed to read system config: {e}"),
156                source: Some(Box::new(e)),
157            })?
158            .build()
159            .map_err(|e| ToolError::Config {
160                message: format!("DNS: failed to construct resolver: {e}"),
161                source: Some(Box::new(e)),
162            })?;
163        Ok(Self {
164            inner,
165            explicit_allow: Arc::new(HashSet::new()),
166        })
167    }
168
169    /// Build a resolver with an explicit `(config, opts)` pair —
170    /// useful for tests or for environments that pin a specific
171    /// upstream resolver.
172    pub fn from_config(config: ResolverConfig, opts: ResolverOpts) -> Result<Self, ToolError> {
173        let inner = TokioResolver::builder_with_config(
174            config,
175            hickory_resolver::net::runtime::TokioRuntimeProvider::default(),
176        )
177        .with_options(opts)
178        .build()
179        .map_err(|e| ToolError::Config {
180            message: format!("DNS: failed to construct resolver: {e}"),
181            source: Some(Box::new(e)),
182        })?;
183        Ok(Self {
184            inner,
185            explicit_allow: Arc::new(HashSet::new()),
186        })
187    }
188
189    /// Replace the explicit-allow set. IPs in the set bypass
190    /// [`is_ssrf_blocked`].
191    #[must_use]
192    pub fn with_explicit_allow(mut self, ips: HashSet<IpAddr>) -> Self {
193        self.explicit_allow = Arc::new(ips);
194        self
195    }
196}
197
198#[allow(
199    clippy::missing_fields_in_debug,
200    reason = "TokioResolver carries a non-Debug closure; printed as the explicit-allow count instead"
201)]
202impl std::fmt::Debug for SsrfSafeDnsResolver {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("SsrfSafeDnsResolver")
205            .field("explicit_allow_count", &self.explicit_allow.len())
206            .finish()
207    }
208}
209
210impl Resolve for SsrfSafeDnsResolver {
211    fn resolve(&self, name: Name) -> Resolving {
212        let inner = self.inner.clone();
213        let allow = Arc::clone(&self.explicit_allow);
214        Box::pin(async move {
215            let host = name.as_str();
216            let lookup = inner
217                .lookup_ip(host)
218                .await
219                .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
220            let mut safe: Vec<SocketAddr> = Vec::new();
221            let mut blocked: Vec<IpAddr> = Vec::new();
222            for ip in lookup.iter() {
223                if allow.contains(&ip) || !is_ssrf_blocked(&ip) {
224                    safe.push(SocketAddr::new(ip, 0));
225                } else {
226                    blocked.push(ip);
227                }
228            }
229            if safe.is_empty() {
230                let msg = format!(
231                    "DNS for '{host}' resolved only to blocked IPs ({blocked:?}); \
232                     refusing to connect (SSRF guard)"
233                );
234                return Err(msg.into());
235            }
236            let iter: Addrs = Box::new(safe.into_iter());
237            Ok(iter)
238        })
239    }
240}
241
242#[cfg(test)]
243#[allow(clippy::unwrap_used, clippy::expect_used, clippy::ip_constant)]
244mod tests {
245    use std::net::{Ipv4Addr, Ipv6Addr};
246
247    use super::*;
248
249    #[test]
250    fn ipv4_loopback_blocked() {
251        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
252    }
253
254    #[test]
255    fn ipv4_metadata_endpoint_blocked() {
256        // EC2 / GCP metadata.
257        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(
258            169, 254, 169, 254
259        ))));
260    }
261
262    #[test]
263    fn ipv4_private_ranges_blocked() {
264        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))));
265        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(172, 16, 1, 1))));
266        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
267    }
268
269    #[test]
270    fn ipv4_cgnat_blocked() {
271        // 100.64.0.0/10 (carrier-grade NAT).
272        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
273        assert!(is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(100, 127, 1, 1))));
274    }
275
276    #[test]
277    fn ipv4_public_address_passes() {
278        // 8.8.8.8 (Google DNS) — unquestionably public.
279        assert!(!is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
280        // 1.1.1.1 (Cloudflare).
281        assert!(!is_ssrf_blocked(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
282    }
283
284    #[test]
285    fn ipv6_loopback_and_unspecified_blocked() {
286        assert!(is_ssrf_blocked(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
287        assert!(is_ssrf_blocked(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
288    }
289
290    #[test]
291    fn ipv6_unique_local_and_link_local_blocked() {
292        // ULA fc00::/7 — pick fd00::1 as a representative.
293        assert!(is_ssrf_blocked(&IpAddr::V6("fd00::1".parse().unwrap())));
294        // Link-local fe80::/10.
295        assert!(is_ssrf_blocked(&IpAddr::V6("fe80::1".parse().unwrap())));
296    }
297
298    #[test]
299    fn ipv4_mapped_ipv6_routes_through_v4_block() {
300        // ::ffff:127.0.0.1 — IPv4-mapped, must inherit the v4 block.
301        assert!(is_ssrf_blocked(&IpAddr::V6(
302            "::ffff:127.0.0.1".parse().unwrap()
303        )));
304        assert!(is_ssrf_blocked(&IpAddr::V6(
305            "::ffff:10.0.0.5".parse().unwrap()
306        )));
307        assert!(is_ssrf_blocked(&IpAddr::V6(
308            "::ffff:169.254.169.254".parse().unwrap()
309        )));
310    }
311
312    #[test]
313    fn ipv4_mapped_public_v4_passes() {
314        // ::ffff:8.8.8.8 — public v4, IPv4-mapped form is also public.
315        assert!(!is_ssrf_blocked(&IpAddr::V6(
316            "::ffff:8.8.8.8".parse().unwrap()
317        )));
318    }
319
320    #[test]
321    fn six_to_four_prefix_blocked_unconditionally() {
322        // 2002:7f00:0001::/48 — 6to4 of 127.0.0.1; whole /16 blocked.
323        assert!(is_ssrf_blocked(&IpAddr::V6("2002::1".parse().unwrap())));
324        assert!(is_ssrf_blocked(&IpAddr::V6(
325            "2002:7f00:0001::".parse().unwrap()
326        )));
327        assert!(is_ssrf_blocked(&IpAddr::V6(
328            "2002:0808:0808::".parse().unwrap()
329        )));
330    }
331
332    #[test]
333    fn teredo_prefix_blocked_unconditionally() {
334        // 2001::/32 — Teredo. NAT-traversal has no legit outbound role.
335        assert!(is_ssrf_blocked(&IpAddr::V6("2001::1".parse().unwrap())));
336        assert!(is_ssrf_blocked(&IpAddr::V6(
337            "2001:0:abcd:ef01::".parse().unwrap()
338        )));
339    }
340
341    #[test]
342    fn non_teredo_2001_prefix_allowed() {
343        // 2001:db8::/32 is documentation, but 2001:4860::/32 is Google
344        // production IPv6 — must NOT collide with the Teredo block.
345        assert!(!is_ssrf_blocked(&IpAddr::V6(
346            "2001:4860:4860::8888".parse().unwrap()
347        )));
348    }
349
350    #[test]
351    fn ipv6_public_address_passes() {
352        // Google public DNS over IPv6.
353        assert!(!is_ssrf_blocked(&IpAddr::V6(
354            "2001:4860:4860::8888".parse().unwrap()
355        )));
356    }
357
358    #[tokio::test]
359    async fn resolver_rejects_when_only_blocked_ips_resolve() {
360        // Build a resolver whose lookup will only return loopback IPs
361        // by pointing at a hosts-style override. Hickory has no
362        // built-in mock, so we exercise the filter directly via the
363        // is_ssrf_blocked function above and assert the
364        // "no safe IPs" error path through the public surface in a
365        // separate integration test under `tests/`.
366        // This unit test smoke-tests construction + Debug surface.
367        let r =
368            SsrfSafeDnsResolver::from_config(ResolverConfig::default(), ResolverOpts::default());
369        assert!(format!("{r:?}").contains("SsrfSafeDnsResolver"));
370    }
371
372    #[test]
373    fn explicit_allow_overrides_block_for_listed_ips() {
374        // Direct invariant test: an IP in the allow set should NOT be
375        // filtered out by the resolver. Tested via a synthetic
376        // address pair.
377        let mut allow = HashSet::new();
378        allow.insert(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
379        let allowed_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
380        let other_blocked = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
381
382        // Mirror the resolver's filter logic.
383        let safe_for_allowed = allow.contains(&allowed_ip) || !is_ssrf_blocked(&allowed_ip);
384        let safe_for_other = allow.contains(&other_blocked) || !is_ssrf_blocked(&other_blocked);
385
386        assert!(safe_for_allowed, "explicit_allow must override block");
387        assert!(!safe_for_other, "non-allowlisted private IP stays blocked");
388    }
389}