Skip to main content

microclaw_engine/internal/tool_runtime/
url_safety.rs

1//! SSRF (Server-Side Request Forgery) protection for URL-fetching tools.
2//!
3//! Resolves each URL's hostname to its IPs and blocks targets that point at
4//! private/internal/cloud-metadata addresses. Inspired by the Python
5//! `url_safety.py` in nousresearch/hermes-agent, with Rust-native resolution
6//! semantics.
7//!
8//! Limitations:
9//! - DNS rebinding (TOCTOU): an attacker-controlled DNS server with TTL=0 can
10//!   return a public IP for the pre-flight check and then a private IP on the
11//!   actual connect. Mitigating this requires connection-level validation
12//!   (e.g. a socket-bound resolver or an egress proxy). This module performs
13//!   pre-flight checks only.
14//! - Redirect-based bypass is caller-responsibility — callers that follow
15//!   redirects themselves should re-validate each hop.
16
17use std::net::{IpAddr, ToSocketAddrs};
18use std::time::Duration;
19
20use reqwest::Url;
21
22/// Hostnames that are always blocked regardless of IP resolution.
23///
24/// These reach cloud-metadata endpoints even when DNS resolves to a public
25/// address (because the cloud platform does local routing).
26const BLOCKED_HOSTNAMES: &[&str] = &["metadata.google.internal", "metadata.goog", "metadata"];
27
28/// CGNAT / Shared Address Space (RFC 6598, 100.64.0.0/10) is not flagged by
29/// `IpAddr::is_private`, so we check it explicitly. Used by carrier-grade
30/// NAT, Tailscale/WireGuard VPNs, and some cloud internal networks.
31fn is_cgnat(ip: &IpAddr) -> bool {
32    match ip {
33        IpAddr::V4(v4) => {
34            let octets = v4.octets();
35            octets[0] == 100 && (octets[1] & 0xC0) == 64
36        }
37        IpAddr::V6(_) => false,
38    }
39}
40
41/// Link-local IPv4 169.254.0.0/16 (covers AWS/Azure/GCP IMDSv1 169.254.169.254)
42/// is marked as link-local by the stdlib but for clarity we assert it.
43fn is_link_local(ip: &IpAddr) -> bool {
44    match ip {
45        IpAddr::V4(v4) => v4.is_link_local(),
46        IpAddr::V6(v6) => {
47            let segments = v6.segments();
48            (segments[0] & 0xFFC0) == 0xFE80
49        }
50    }
51}
52
53/// Returns true if the IP address belongs to a range that SSRF guards should
54/// reject. Combines stdlib predicates with explicit CGNAT + extra-safe
55/// link-local handling.
56pub fn is_blocked_ip(ip: &IpAddr) -> bool {
57    if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
58        return true;
59    }
60    if is_link_local(ip) {
61        return true;
62    }
63    if is_cgnat(ip) {
64        return true;
65    }
66    match ip {
67        IpAddr::V4(v4) => {
68            if v4.is_private() {
69                return true;
70            }
71            if v4.is_broadcast() {
72                return true;
73            }
74            // IETF Protocol Assignments 192.0.0.0/24, documentation ranges,
75            // and the benchmarking 198.18.0.0/15 range.
76            let octets = v4.octets();
77            if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
78                return true;
79            }
80            if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
81                return true;
82            }
83            false
84        }
85        IpAddr::V6(v6) => {
86            // Unique local fc00::/7 and IPv4-mapped forms of private ranges.
87            let segs = v6.segments();
88            if (segs[0] & 0xFE00) == 0xFC00 {
89                return true;
90            }
91            if let Some(v4) = v6.to_ipv4_mapped() {
92                return is_blocked_ip(&IpAddr::V4(v4));
93            }
94            false
95        }
96    }
97}
98
99/// Resolve `hostname` via the system resolver with a short timeout and
100/// return all IP addresses. DNS failures return `None` (caller should fail
101/// closed).
102fn resolve_hostname(hostname: &str, timeout: Duration) -> Option<Vec<IpAddr>> {
103    let host = hostname.to_string();
104    let handle = std::thread::spawn(move || {
105        (host.as_str(), 0u16)
106            .to_socket_addrs()
107            .ok()
108            .map(|iter| iter.map(|sa| sa.ip()).collect::<Vec<_>>())
109    });
110
111    let start = std::time::Instant::now();
112    loop {
113        if handle.is_finished() {
114            return handle.join().ok().flatten();
115        }
116        if start.elapsed() >= timeout {
117            // Leak the resolver thread rather than block; fail closed.
118            return None;
119        }
120        std::thread::sleep(Duration::from_millis(20));
121    }
122}
123
124/// Pre-flight SSRF check for an already-parsed URL. Returns `Err(message)`
125/// when the URL targets an address that should be blocked.
126pub fn check_url_private_ip(url: &Url) -> Result<(), String> {
127    let Some(host) = url.host_str() else {
128        return Err("URL has no host".to_string());
129    };
130
131    let host_lower = host.to_ascii_lowercase();
132    let hostname = host_lower.trim_end_matches('.');
133
134    if BLOCKED_HOSTNAMES.contains(&hostname) {
135        return Err(format!(
136            "URL host '{host}' points at an internal metadata endpoint"
137        ));
138    }
139
140    // If host is already an IP literal, validate it directly. Bracketed
141    // IPv6 forms (e.g. "[::1]") come through `Url::host_str()` without
142    // brackets in most parsers, but strip them defensively.
143    let ip_candidate = hostname.trim_start_matches('[').trim_end_matches(']');
144    if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
145        if is_blocked_ip(&ip) {
146            return Err(format!(
147                "URL host '{host}' resolves to a blocked address ({ip})"
148            ));
149        }
150        return Ok(());
151    }
152
153    let Some(ips) = resolve_hostname(hostname, Duration::from_secs(3)) else {
154        return Err(format!(
155            "URL host '{host}' failed DNS resolution (SSRF check fails closed)"
156        ));
157    };
158
159    if ips.is_empty() {
160        return Err(format!("URL host '{host}' has no resolved IPs"));
161    }
162
163    for ip in &ips {
164        if is_blocked_ip(ip) {
165            return Err(format!(
166                "URL host '{host}' resolves to a blocked private/internal address ({ip})"
167            ));
168        }
169    }
170    Ok(())
171}
172
173/// Pre-flight SSRF check for a raw URL string. Convenience wrapper that
174/// parses the URL first.
175pub fn check_raw_url_private_ip(raw_url: &str) -> Result<(), String> {
176    let parsed = Url::parse(raw_url).map_err(|e| format!("invalid URL: {e}"))?;
177    check_url_private_ip(&parsed)
178}
179
180/// A reqwest redirect policy that re-validates each hop against the SSRF
181/// guard. Use with `reqwest::ClientBuilder::redirect(policy)` to catch
182/// redirects inside third-party SDKs or forwarded requests that would
183/// otherwise bypass the manual redirect loop in `web_fetch`.
184///
185/// Stops the redirect chain (returning an error that the caller sees as
186/// a request failure) when a hop targets a blocked address. Follows up
187/// to `max_redirects` hops when every hop passes.
188pub fn ssrf_redirect_policy(max_redirects: usize) -> reqwest::redirect::Policy {
189    reqwest::redirect::Policy::custom(move |attempt| {
190        if attempt.previous().len() >= max_redirects {
191            return attempt.error(format!("too many redirects (>{max_redirects})"));
192        }
193        if let Err(e) = check_url_private_ip(attempt.url()) {
194            return attempt.error(format!("SSRF guard blocked redirect: {e}"));
195        }
196        attempt.follow()
197    })
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn blocks_loopback_literal() {
206        let err = check_raw_url_private_ip("http://127.0.0.1/").unwrap_err();
207        assert!(err.contains("blocked"));
208    }
209
210    #[test]
211    fn blocks_cloud_metadata_literal() {
212        let err = check_raw_url_private_ip("http://169.254.169.254/latest/meta-data/").unwrap_err();
213        assert!(err.contains("blocked"));
214    }
215
216    #[test]
217    fn blocks_cloud_metadata_hostname() {
218        let err = check_raw_url_private_ip("http://metadata.google.internal/").unwrap_err();
219        assert!(err.contains("metadata"));
220    }
221
222    #[test]
223    fn blocks_private_range_literal() {
224        let err = check_raw_url_private_ip("http://10.0.0.1/").unwrap_err();
225        assert!(err.contains("blocked"));
226        let err = check_raw_url_private_ip("http://192.168.1.1/").unwrap_err();
227        assert!(err.contains("blocked"));
228        let err = check_raw_url_private_ip("http://172.16.0.1/").unwrap_err();
229        assert!(err.contains("blocked"));
230    }
231
232    #[test]
233    fn blocks_cgnat_literal() {
234        let err = check_raw_url_private_ip("http://100.64.1.2/").unwrap_err();
235        assert!(err.contains("blocked"));
236    }
237
238    #[test]
239    fn blocks_ipv6_loopback() {
240        let err = check_raw_url_private_ip("http://[::1]/").unwrap_err();
241        assert!(err.contains("blocked"));
242    }
243
244    #[test]
245    fn blocks_unique_local_ipv6() {
246        let err = check_raw_url_private_ip("http://[fc00::1]/").unwrap_err();
247        assert!(err.contains("blocked"));
248    }
249
250    #[test]
251    fn allows_public_ipv4_literal() {
252        assert!(check_raw_url_private_ip("https://1.1.1.1/").is_ok());
253    }
254
255    #[test]
256    fn rejects_missing_host() {
257        // reqwest parses file: URLs but they have no host.
258        let err = check_raw_url_private_ip("file:///etc/passwd").unwrap_err();
259        assert!(err.contains("no host") || err.contains("invalid URL"));
260    }
261}