Skip to main content

lc_tools/extended/
http.rs

1//! HTTP tool with SSRF protection
2
3use 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
12/// Check if an IP address is private/internal (SSRF protection).
13fn 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
33/// Resolve a URL hostname and check if it points to a private IP.
34async 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
65/// HTTP request tool (GET/POST) with SSRF protection.
66pub 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    /// Allow requests to private/internal IP addresses (SSRF opt-in).
93    pub fn with_allow_private_ips(mut self, allow: bool) -> Self {
94        self.allow_private_ips = allow;
95        self
96    }
97
98    /// Check SSRF protection before making a request.
99    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}