microclaw_engine/internal/tool_runtime/
url_safety.rs1use std::net::{IpAddr, ToSocketAddrs};
18use std::time::Duration;
19
20use reqwest::Url;
21
22const BLOCKED_HOSTNAMES: &[&str] = &["metadata.google.internal", "metadata.goog", "metadata"];
27
28fn 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
41fn 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
53pub 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 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 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
99fn 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 return None;
119 }
120 std::thread::sleep(Duration::from_millis(20));
121 }
122}
123
124pub 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 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
173pub 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
180pub 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 let err = check_raw_url_private_ip("file:///etc/passwd").unwrap_err();
259 assert!(err.contains("no host") || err.contains("invalid URL"));
260 }
261}