1use std::net::{IpAddr, SocketAddr};
13use std::time::Duration;
14
15use crate::tools::ToolError;
16
17pub fn is_private_ip(ip: &IpAddr) -> bool {
26 match ip {
27 IpAddr::V4(v4) => {
28 if v4.octets()[0] == 0 {
30 return true;
31 }
32 is_private_ipv4(*v4)
33 }
34 IpAddr::V6(v6) => {
35 if let Some(v4) = v6.to_ipv4_mapped() {
39 return is_private_ip(&IpAddr::V4(v4));
40 }
41 is_private_ipv6(*v6)
42 }
43 }
44}
45
46fn is_private_ipv4(v4: std::net::Ipv4Addr) -> bool {
48 let [a, b, _, _] = v4.octets();
49 match a {
50 0 => true,
52 10 => true,
54 100 => b & 0b1100_0000 == 0b0100_0000,
56 127 => true,
58 169 => b == 254,
60 172 => (16..=31).contains(&b),
62 192 if b == 0 => true,
64 192 if b == 88 => true,
66 192 if b == 168 => true,
68 198 => (b & 0xfe) == 0x12 || b == 51,
70 203 if b == 0 => v4.octets()[2] == 113,
72 224..=255 => true,
74 _ => false,
75 }
76}
77
78fn is_private_ipv6(v6: std::net::Ipv6Addr) -> bool {
80 let seg = v6.segments();
81 if v6.is_loopback() {
83 return true;
84 }
85 if (seg[0] & 0xfe00) == 0xfc00 {
87 return true;
88 }
89 if matches!(seg, [0xfe80, ..]) {
91 return true;
92 }
93 if v6 == std::net::Ipv6Addr::UNSPECIFIED {
95 return true;
96 }
97 if (seg[0] & 0xffc0) == 0xfec0 {
99 return true;
100 }
101 if (seg[0] & 0xff00) == 0xff00 {
103 return true;
104 }
105 if seg[0] == 0x2001 && seg[1] == 0x0db8 {
107 return true;
108 }
109 seg[0] == 0x2002
111}
112
113pub const DEFAULT_GUARDED_TIMEOUT: Duration = Duration::from_secs(30);
115
116pub async fn url_points_to_private_ip(url: &str) -> Result<bool, ToolError> {
123 let parsed = parse_http_url(url)?;
124 let addrs = resolve_url_addrs(&parsed).await?;
125 Ok(addrs.iter().any(|sa| is_private_ip(&sa.ip())))
126}
127
128fn parse_http_url(url: &str) -> Result<url::Url, ToolError> {
130 let parsed =
131 url::Url::parse(url).map_err(|e| ToolError::InvalidInput(format!("Invalid URL: {}", e)))?;
132 if !matches!(parsed.scheme(), "http" | "https") {
133 return Err(ToolError::InvalidInput(format!(
134 "URL scheme not supported: {}",
135 parsed.scheme()
136 )));
137 }
138 if parsed.host_str().is_none() {
139 return Err(ToolError::InvalidInput("URL has no host".to_string()));
140 }
141 Ok(parsed)
142}
143
144async fn resolve_url_addrs(parsed: &url::Url) -> Result<Vec<SocketAddr>, ToolError> {
153 let host = parsed
154 .host_str()
155 .ok_or_else(|| ToolError::InvalidInput("URL has no host".to_string()))?;
156 let port = parsed.port_or_known_default().unwrap_or(80);
157
158 if let Ok(ip) = host.parse::<IpAddr>() {
160 return Ok(vec![SocketAddr::new(ip, port)]);
161 }
162
163 let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
164 .await
165 .map_err(|e| {
166 ToolError::ExecutionFailed(format!("DNS resolution failed for {}: {}", host, e))
167 })?
168 .collect();
169
170 if addrs.is_empty() {
171 return Err(ToolError::ExecutionFailed(format!(
172 "DNS resolution returned no addresses for {}",
173 host
174 )));
175 }
176 Ok(addrs)
177}
178
179fn ensure_all_public(addrs: &[SocketAddr]) -> Result<(), ToolError> {
181 if let Some(bad) = addrs.iter().map(SocketAddr::ip).find(is_private_ip) {
182 return Err(ToolError::ExecutionFailed(format!(
183 "Request to private/internal IP address ({bad}) is blocked by SSRF protection. \
184 Call .with_allow_private_ips(true) to allow."
185 )));
186 }
187 Ok(())
188}
189
190fn pinned_client(
197 host: &str,
198 addrs: &[SocketAddr],
199 timeout: Option<Duration>,
200) -> Result<reqwest::Client, ToolError> {
201 let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
202 builder = builder.timeout(timeout.unwrap_or(DEFAULT_GUARDED_TIMEOUT));
203 if host.parse::<IpAddr>().is_err() {
204 builder = builder.resolve_to_addrs(host, addrs);
205 }
206 builder
207 .build()
208 .map_err(|e| ToolError::ExecutionFailed(format!("failed to build HTTP client: {}", e)))
209}
210
211const MAX_REDIRECTS: usize = 10;
213
214pub async fn guarded_get(
233 url: &str,
234 check_ssrf: bool,
235 timeout: Option<Duration>,
236) -> Result<reqwest::Response, ToolError> {
237 let mut current = url.to_string();
238 for _ in 0..=MAX_REDIRECTS {
239 let parsed = parse_http_url(¤t)?;
240 let host = parsed.host_str().expect("host checked above").to_string();
241 let addrs = resolve_url_addrs(&parsed).await?;
242 if check_ssrf {
243 ensure_all_public(&addrs)?;
244 }
245 let client = pinned_client(&host, &addrs, timeout)?;
246
247 let resp = client
248 .get(¤t)
249 .send()
250 .await
251 .map_err(|e| ToolError::ExecutionFailed(format!("HTTP request failed: {}", e)))?;
252
253 if !resp.status().is_redirection() {
254 return Ok(resp);
255 }
256
257 let Some(location) = resp
259 .headers()
260 .get(reqwest::header::LOCATION)
261 .and_then(|v| v.to_str().ok())
262 else {
263 return Ok(resp);
264 };
265 current = resolve_redirect(¤t, location)?;
266 }
267 Err(ToolError::ExecutionFailed(format!(
268 "request redirect count exceeded the limit of {} times",
269 MAX_REDIRECTS
270 )))
271}
272
273pub async fn guarded_post_json(
277 url: &str,
278 body: &serde_json::Value,
279 check_ssrf: bool,
280 timeout: Option<Duration>,
281) -> Result<reqwest::Response, ToolError> {
282 let parsed = parse_http_url(url)?;
283 let host = parsed.host_str().expect("host checked above").to_string();
284 let addrs = resolve_url_addrs(&parsed).await?;
285 if check_ssrf {
286 ensure_all_public(&addrs)?;
287 }
288 let client = pinned_client(&host, &addrs, timeout)?;
289
290 client
291 .post(url)
292 .json(body)
293 .send()
294 .await
295 .map_err(|e| ToolError::ExecutionFailed(format!("HTTP request failed: {}", e)))
296}
297
298fn resolve_redirect(base: &str, location: &str) -> Result<String, ToolError> {
300 let joined = url::Url::parse(base)
301 .and_then(|base_url| base_url.join(location))
302 .map_err(|e| ToolError::InvalidInput(format!("invalid redirect target: {}", e)))?;
303 if joined.scheme() != "http" && joined.scheme() != "https" {
304 return Err(ToolError::InvalidInput(format!(
305 "redirect target protocol not supported: {}",
306 joined.scheme()
307 )));
308 }
309 Ok(joined.to_string())
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn ipv4_mapped_ipv6_private_is_blocked() {
318 assert!(is_private_ip(
319 &"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
320 ));
321 assert!(is_private_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
322 assert!(is_private_ip(
323 &"::ffff:169.254.169.254".parse::<IpAddr>().unwrap()
324 ));
325 assert!(is_private_ip(
326 &"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
327 ));
328 assert!(is_private_ip(
329 &"::ffff:172.16.0.1".parse::<IpAddr>().unwrap()
330 ));
331 }
332
333 #[test]
334 fn ipv4_mapped_ipv6_public_allowed() {
335 assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
336 assert!(!is_private_ip(&"::ffff:1.1.1.1".parse::<IpAddr>().unwrap()));
337 }
338
339 #[test]
340 fn regular_ipv6_unchanged() {
341 assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
342 assert!(is_private_ip(&"fc00::1".parse::<IpAddr>().unwrap()));
343 assert!(is_private_ip(&"fe80::1".parse::<IpAddr>().unwrap()));
344 assert!(is_private_ip(&"2001:db8::1".parse::<IpAddr>().unwrap()));
346 assert!(!is_private_ip(
348 &"2606:4700:4700::1111".parse::<IpAddr>().unwrap()
349 ));
350 }
351
352 #[test]
353 fn ipv4_special_ranges_are_blocked() {
354 let blocked: &[&str] = &[
356 "100.64.0.1", "100.127.255.1", "198.18.0.1", "198.19.255.1", "192.0.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "240.0.0.1", "0.1.2.3", ];
368 for s in blocked {
369 assert!(
370 is_private_ip(&s.parse::<IpAddr>().unwrap()),
371 "expected {s} to be flagged"
372 );
373 }
374
375 for s in &["100.128.0.1", "198.20.0.1", "8.8.8.8", "1.1.1.1"] {
377 assert!(
378 !is_private_ip(&s.parse::<IpAddr>().unwrap()),
379 "expected {s} to be allowed"
380 );
381 }
382 }
383
384 #[test]
385 fn resolve_redirect_relative_and_absolute() {
386 assert_eq!(
387 resolve_redirect("https://a.com/x", "/internal").unwrap(),
388 "https://a.com/internal"
389 );
390 assert_eq!(
391 resolve_redirect("https://a.com/x", "https://b.com/y").unwrap(),
392 "https://b.com/y"
393 );
394 }
395
396 #[test]
397 fn resolve_redirect_rejects_non_http() {
398 assert!(resolve_redirect("https://a.com/x", "file:///etc/passwd").is_err());
399 assert!(resolve_redirect("https://a.com/x", "ftp://b.com").is_err());
400 }
401
402 #[test]
405 fn parse_http_url_rejects_scheme_and_missing_host() {
406 assert!(parse_http_url("file:///etc/passwd").is_err());
407 assert!(parse_http_url("ftp://b.com/x").is_err());
408 assert!(parse_http_url("not a url").is_err());
409 assert!(parse_http_url("http://").is_err());
411 assert!(parse_http_url("https://example.com/x").is_ok());
412 }
413
414 #[test]
415 fn ensure_all_public_blocks_when_any_answer_is_private() {
416 let mixed: Vec<SocketAddr> = vec![
418 "8.8.8.8:443".parse().unwrap(),
419 "10.0.0.5:443".parse().unwrap(),
420 "1.1.1.1:443".parse().unwrap(),
421 ];
422 let err = ensure_all_public(&mixed).unwrap_err();
423 assert!(err.to_string().contains("SSRF"), "got: {err}");
424
425 let public: Vec<SocketAddr> = vec![
426 "8.8.8.8:443".parse().unwrap(),
427 "[2606:4700:4700::1111]:443".parse().unwrap(),
428 ];
429 ensure_all_public(&public).expect("all-public set passes");
430
431 let mapped: Vec<SocketAddr> = vec!["[::ffff:169.254.169.254]:80".parse().unwrap()];
433 assert!(ensure_all_public(&mapped).is_err());
434 }
435
436 #[tokio::test]
437 async fn resolve_url_addrs_ip_literals_bypass_dns() {
438 let url = parse_http_url("http://127.0.0.1:8080/").unwrap();
441 let addrs = resolve_url_addrs(&url).await.unwrap();
442 assert_eq!(addrs, vec!["127.0.0.1:8080".parse::<SocketAddr>().unwrap()]);
443
444 let url = parse_http_url("https://8.8.8.8/").unwrap();
445 let addrs = resolve_url_addrs(&url).await.unwrap();
446 assert_eq!(addrs, vec!["8.8.8.8:443".parse::<SocketAddr>().unwrap()]);
447 }
448
449 #[tokio::test]
450 async fn guarded_get_blocks_loopback_before_connecting() {
451 let err = guarded_get("http://127.0.0.1:1/", true, None)
455 .await
456 .unwrap_err();
457 assert!(err.to_string().contains("SSRF"), "got: {err}");
458 }
459
460 #[tokio::test]
461 async fn guarded_get_blocks_link_local_before_connecting() {
462 let err = guarded_get("http://[::ffff:169.254.169.254]/latest", true, None)
463 .await
464 .unwrap_err();
465 assert!(err.to_string().contains("SSRF"), "got: {err}");
466 }
467
468 #[tokio::test]
469 async fn guarded_get_rejects_non_http_scheme_without_sending() {
470 let err = guarded_get("file:///etc/passwd", true, None)
471 .await
472 .unwrap_err();
473 assert!(err.to_string().contains("scheme"), "got: {err}");
474 }
475
476 #[tokio::test]
477 async fn guarded_post_json_blocks_private_before_connecting() {
478 let err = guarded_post_json(
479 "http://169.254.169.254/latest/meta-data/",
480 &serde_json::json!({}),
481 true,
482 None,
483 )
484 .await
485 .unwrap_err();
486 assert!(err.to_string().contains("SSRF"), "got: {err}");
487 }
488}