lc_tools/extended/
http.rs1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
4use std::time::Duration;
5
6use async_trait::async_trait;
7use serde_json::Value;
8
9use lc_core::tools::ToolError;
10use lc_core::BaseTool;
11
12fn is_private_ip(ip: &IpAddr) -> bool {
14 match ip {
15 IpAddr::V4(v4) => {
16 let octets = v4.octets();
17 octets[0] == 127
18 || octets[0] == 10
19 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31)
20 || (octets[0] == 192 && octets[1] == 168)
21 || (octets[0] == 169 && octets[1] == 254)
22 || *v4 == Ipv4Addr::UNSPECIFIED
23 }
24 IpAddr::V6(v6) => {
25 v6.is_loopback()
26 || (v6.segments()[0] & 0xfe00) == 0xfc00
27 || matches!(v6.segments(), [0xfe80, ..])
28 || *v6 == Ipv6Addr::UNSPECIFIED
29 }
30 }
31}
32
33async fn url_points_to_private_ip(url: &str) -> Result<bool, ToolError> {
35 let parsed =
36 url::Url::parse(url).map_err(|e| ToolError::InvalidInput(format!("Invalid URL: {}", e)))?;
37 let host = parsed
38 .host_str()
39 .ok_or_else(|| ToolError::InvalidInput("URL has no host".to_string()))?;
40
41 if let Ok(ip) = host.parse::<IpAddr>() {
42 return Ok(is_private_ip(&ip));
43 }
44
45 let port = parsed.port_or_known_default().unwrap_or(80);
46 let addr_str = format!("{}:{}", host, port);
47 let addrs: Vec<IpAddr> = tokio::net::lookup_host(&addr_str)
48 .await
49 .map_err(|e| {
50 ToolError::ExecutionFailed(format!("DNS resolution failed for {}: {}", host, e))
51 })?
52 .map(|sa: SocketAddr| sa.ip())
53 .collect();
54
55 if addrs.is_empty() {
56 return Err(ToolError::ExecutionFailed(format!(
57 "DNS resolution returned no addresses for {}",
58 host
59 )));
60 }
61
62 Ok(addrs.iter().any(is_private_ip))
63}
64
65pub struct HTTPTool {
67 client: reqwest::Client,
68 allow_private_ips: bool,
69}
70
71impl HTTPTool {
72 pub fn new() -> Self {
73 Self {
74 client: reqwest::Client::builder()
75 .timeout(Duration::from_secs(30))
76 .build()
77 .unwrap_or_else(|_| reqwest::Client::new()),
78 allow_private_ips: false,
79 }
80 }
81
82 pub fn with_timeout(timeout: Duration) -> Self {
83 Self {
84 client: reqwest::Client::builder()
85 .timeout(timeout)
86 .build()
87 .unwrap_or_else(|_| reqwest::Client::new()),
88 allow_private_ips: false,
89 }
90 }
91
92 pub fn with_allow_private_ips(mut self, allow: bool) -> Self {
94 self.allow_private_ips = allow;
95 self
96 }
97
98 async fn check_ssrf(&self, url: &str) -> Result<(), ToolError> {
100 if self.allow_private_ips {
101 return Ok(());
102 }
103 if url_points_to_private_ip(url).await? {
104 return Err(ToolError::ExecutionFailed(
105 "Request to private/internal IP address is blocked by SSRF protection. \
106 Call .with_allow_private_ips(true) to allow."
107 .to_string(),
108 ));
109 }
110 Ok(())
111 }
112
113 pub async fn get(&self, url: &str) -> Result<String, ToolError> {
114 self.check_ssrf(url).await?;
115 self.client
116 .get(url)
117 .send()
118 .await
119 .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?
120 .text()
121 .await
122 .map_err(|e| ToolError::ExecutionFailed(e.to_string()))
123 }
124
125 pub async fn post(&self, url: &str, body: Value) -> Result<String, ToolError> {
126 self.check_ssrf(url).await?;
127 self.client
128 .post(url)
129 .json(&body)
130 .send()
131 .await
132 .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?
133 .text()
134 .await
135 .map_err(|e| ToolError::ExecutionFailed(e.to_string()))
136 }
137}
138
139impl Default for HTTPTool {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145#[async_trait]
146impl BaseTool for HTTPTool {
147 fn name(&self) -> &str {
148 "http_request"
149 }
150
151 fn description(&self) -> &str {
152 "Make HTTP requests. Input JSON: {\"url\": \"...\", \"method\": \"get|post\", \"body\": {...}}. \
153 SSRF protection enabled by default (blocks private IPs)."
154 }
155
156 async fn run(&self, input: String) -> Result<String, ToolError> {
157 let v: Value =
158 serde_json::from_str(&input).map_err(|e| ToolError::InvalidInput(e.to_string()))?;
159 let url = v
160 .get("url")
161 .and_then(|x| x.as_str())
162 .ok_or_else(|| ToolError::InvalidInput("Missing 'url' field".to_string()))?;
163 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("get");
164 match method {
165 "get" => self.get(url).await,
166 "post" => {
167 self.post(url, v.get("body").cloned().unwrap_or(Value::Null))
168 .await
169 }
170 other => Err(ToolError::InvalidInput(format!(
171 "Unknown method: {}. Supported: get, post",
172 other
173 ))),
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_name_description() {
184 let t = HTTPTool::new();
185 assert_eq!(t.name(), "http_request");
186 assert!(t.description().contains("HTTP"));
187 }
188
189 #[test]
190 fn test_private_ip_detection() {
191 assert!(is_private_ip(&IpAddr::from([127, 0, 0, 1])));
192 assert!(is_private_ip(&IpAddr::from([10, 0, 0, 1])));
193 assert!(is_private_ip(&IpAddr::from([172, 16, 0, 1])));
194 assert!(is_private_ip(&IpAddr::from([172, 31, 255, 255])));
195 assert!(is_private_ip(&IpAddr::from([192, 168, 1, 1])));
196 assert!(is_private_ip(&IpAddr::from([169, 254, 169, 254])));
197 assert!(is_private_ip(&IpAddr::from([0, 0, 0, 0])));
198
199 assert!(!is_private_ip(&IpAddr::from([8, 8, 8, 8])));
200 assert!(!is_private_ip(&IpAddr::from([1, 1, 1, 1])));
201 assert!(!is_private_ip(&IpAddr::from([172, 15, 0, 1])));
202 assert!(!is_private_ip(&IpAddr::from([172, 32, 0, 1])));
203 }
204
205 #[tokio::test]
206 async fn test_ssrf_blocks_localhost() {
207 let tool = HTTPTool::new();
208 let result = tool.check_ssrf("http://127.0.0.1:6379/").await;
209 assert!(result.is_err());
210 assert!(result.unwrap_err().to_string().contains("SSRF"));
211 }
212
213 #[tokio::test]
214 async fn test_ssrf_blocks_cloud_metadata() {
215 let tool = HTTPTool::new();
216 let result = tool
217 .check_ssrf("http://169.254.169.254/latest/meta-data/")
218 .await;
219 assert!(result.is_err());
220 }
221
222 #[tokio::test]
223 async fn test_ssrf_allows_when_opt_in() {
224 let tool = HTTPTool::new().with_allow_private_ips(true);
225 let result = tool.check_ssrf("http://127.0.0.1:6379/").await;
226 assert!(result.is_ok());
227 }
228
229 #[tokio::test]
230 async fn test_run_invalid_json() {
231 let t = HTTPTool::new();
232 assert!(t.run("not json".to_string()).await.is_err());
233 }
234
235 #[tokio::test]
236 async fn test_run_missing_url() {
237 let t = HTTPTool::new();
238 assert!(t.run(r#"{"method":"get"}"#.to_string()).await.is_err());
239 }
240
241 #[tokio::test]
242 async fn test_run_unknown_method() {
243 let t = HTTPTool::new();
244 let r = t
245 .run(r#"{"url":"http://x","method":"put"}"#.to_string())
246 .await;
247 assert!(r.is_err());
248 }
249}