Skip to main content

bamboo_tools/tools/
web_fetch.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use futures::StreamExt;
4use regex::Regex;
5use serde::Deserialize;
6use serde_json::json;
7use std::net::{IpAddr, Ipv4Addr, SocketAddr};
8use std::sync::OnceLock;
9use std::time::Duration;
10
11const MAX_RESPONSE_BYTES: usize = 1_000_000;
12/// Cap on redirect hops we follow manually (reqwest's auto-follow is disabled so
13/// each hop can be re-validated against the SSRF guard).
14const MAX_REDIRECTS: usize = 10;
15
16// Static, compile-time-constant patterns: compile each exactly once and reuse.
17// `expect` is safe here because the patterns are hardcoded and verified valid.
18static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
19static STYLE_RE: OnceLock<Regex> = OnceLock::new();
20static TAG_RE: OnceLock<Regex> = OnceLock::new();
21static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
22
23#[derive(Debug, Deserialize)]
24struct WebFetchArgs {
25    url: String,
26    prompt: String,
27}
28
29pub struct WebFetchTool;
30
31impl WebFetchTool {
32    pub fn new() -> Self {
33        Self
34    }
35
36    fn strip_html(input: &str) -> Result<String, ToolError> {
37        let script_re = SCRIPT_RE.get_or_init(|| {
38            Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("valid static regex")
39        });
40        let style_re = STYLE_RE.get_or_init(|| {
41            Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("valid static regex")
42        });
43        let tag_re =
44            TAG_RE.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("valid static regex"));
45        let whitespace_re =
46            WHITESPACE_RE.get_or_init(|| Regex::new(r"[ \t\n\r]+").expect("valid static regex"));
47
48        let without_scripts = script_re.replace_all(input, " ");
49        let without_styles = style_re.replace_all(&without_scripts, " ");
50        let without_tags = tag_re.replace_all(&without_styles, " ");
51        Ok(whitespace_re
52            .replace_all(&without_tags, " ")
53            .trim()
54            .to_string())
55    }
56
57    fn is_disallowed_ipv4(ipv4: Ipv4Addr) -> bool {
58        let octets = ipv4.octets();
59        // 100.64.0.0/10 — CGNAT shared address space (Ipv4Addr::is_shared is
60        // still unstable, so match the range directly).
61        let is_shared = octets[0] == 100 && (64..=127).contains(&octets[1]);
62        // 0.0.0.0/8 — `is_unspecified()` only matches the exact 0.0.0.0, but the
63        // whole /8 is routed as loopback-equivalent on several OSes.
64        let is_this_network = octets[0] == 0;
65        ipv4.is_loopback()
66            || ipv4.is_private()
67            || ipv4.is_link_local()
68            || ipv4.is_multicast()
69            || ipv4.is_unspecified()
70            || is_shared
71            || is_this_network
72    }
73
74    /// The IPv4 address embedded in an IPv6 address, across the forms that carry
75    /// a routable v4 target: IPv4-mapped (`::ffff:a.b.c.d`), the deprecated
76    /// IPv4-compatible (`::a.b.c.d`), and NAT64 (`64:ff9b::/96`). Returns `None`
77    /// for `::` / `::1`, which the plain IPv6 checks already handle.
78    fn embedded_ipv4(ipv6: std::net::Ipv6Addr) -> Option<Ipv4Addr> {
79        if let Some(mapped) = ipv6.to_ipv4_mapped() {
80            return Some(mapped);
81        }
82        let seg = ipv6.segments();
83        let low = Ipv4Addr::new(
84            (seg[6] >> 8) as u8,
85            (seg[6] & 0xff) as u8,
86            (seg[7] >> 8) as u8,
87            (seg[7] & 0xff) as u8,
88        );
89        // NAT64 well-known prefix 64:ff9b::/96.
90        if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6].iter().all(|s| *s == 0) {
91            return Some(low);
92        }
93        // Deprecated IPv4-compatible ::a.b.c.d (high 96 bits zero), excluding
94        // :: (unspecified) and ::1 (loopback).
95        if seg[0..6].iter().all(|s| *s == 0) && !(seg[6] == 0 && (seg[7] == 0 || seg[7] == 1)) {
96            return Some(low);
97        }
98        None
99    }
100
101    fn is_disallowed_ip(ip: IpAddr) -> bool {
102        match ip {
103            IpAddr::V4(ipv4) => Self::is_disallowed_ipv4(ipv4),
104            IpAddr::V6(ipv6) => {
105                // An embedded IPv4 target (mapped / compatible / NAT64) is judged
106                // by the IPv4 rules — otherwise ::ffff:127.0.0.1, ::169.254.169.254,
107                // 64:ff9b::169.254.169.254 etc. slip past the IPv6-only checks.
108                if let Some(v4) = Self::embedded_ipv4(ipv6) {
109                    if Self::is_disallowed_ipv4(v4) {
110                        return true;
111                    }
112                }
113                let segments = ipv6.segments();
114                let first = segments[0];
115                let is_unique_local = (first & 0xfe00) == 0xfc00;
116                let is_unicast_link_local = (first & 0xffc0) == 0xfe80;
117                ipv6.is_loopback()
118                    || ipv6.is_multicast()
119                    || ipv6.is_unspecified()
120                    || is_unique_local
121                    || is_unicast_link_local
122            }
123        }
124    }
125
126    /// Validate a URL's host against the SSRF guard and return the concrete
127    /// addresses to connect to. Resolving here and **pinning** these exact
128    /// addresses on the request (so reqwest doesn't re-resolve) closes the
129    /// DNS-rebinding TOCTOU where the validated IP differs from the connected IP.
130    /// Rejects if the host is disallowed or *any* resolved address is restricted.
131    async fn validate_and_resolve(url: &url::Url) -> Result<(String, Vec<SocketAddr>), ToolError> {
132        let host = url
133            .host_str()
134            .ok_or_else(|| ToolError::InvalidArguments("URL must include a host".to_string()))?;
135        if Self::is_disallowed_host(host) {
136            return Err(ToolError::Execution(format!(
137                "Refusing to fetch restricted host: {}",
138                host
139            )));
140        }
141        let port = url.port_or_known_default().unwrap_or(80);
142
143        let addrs: Vec<SocketAddr> = if let Ok(ip) = host.parse::<IpAddr>() {
144            vec![SocketAddr::new(ip, port)]
145        } else {
146            tokio::net::lookup_host((host, port))
147                .await
148                .map_err(|e| {
149                    ToolError::Execution(format!("Failed to resolve host '{}': {}", host, e))
150                })?
151                .collect()
152        };
153
154        if addrs.is_empty() {
155            return Err(ToolError::Execution(format!(
156                "Host '{}' resolved to no addresses",
157                host
158            )));
159        }
160        if Self::resolved_ips_include_disallowed(addrs.iter().map(|addr| addr.ip())) {
161            return Err(ToolError::Execution(format!(
162                "Refusing to fetch host '{}' because it resolved to a restricted IP",
163                host
164            )));
165        }
166
167        Ok((host.to_string(), addrs))
168    }
169
170    fn is_disallowed_host(host: &str) -> bool {
171        let host = host.trim().to_ascii_lowercase();
172        if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
173            return true;
174        }
175
176        let Ok(ip) = host.parse::<IpAddr>() else {
177            return false;
178        };
179        Self::is_disallowed_ip(ip)
180    }
181
182    fn resolved_ips_include_disallowed<I>(ips: I) -> bool
183    where
184        I: IntoIterator<Item = IpAddr>,
185    {
186        ips.into_iter().any(Self::is_disallowed_ip)
187    }
188}
189
190impl Default for WebFetchTool {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[async_trait]
197impl Tool for WebFetchTool {
198    fn name(&self) -> &str {
199        "WebFetch"
200    }
201
202    fn description(&self) -> &str {
203        "Fetch an HTTP(S) URL and return a cleaned text excerpt plus metadata. The `prompt` field is caller context only; this tool does not run an extra model."
204    }
205
206    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
207        ToolClass::READONLY_PARALLEL.promotable()
208    }
209
210    fn parameters_schema(&self) -> serde_json::Value {
211        json!({
212            "type": "object",
213            "properties": {
214                "url": {
215                    "type": "string",
216                    "format": "uri",
217                    "description": "The URL to fetch"
218                },
219                "prompt": {
220                    "type": "string",
221                    "description": "Caller-supplied extraction intent note; echoed in output for downstream processing"
222                }
223            },
224            "required": ["url", "prompt"],
225            "additionalProperties": false
226        })
227    }
228
229    async fn invoke(
230        &self,
231        args: serde_json::Value,
232        _ctx: ToolCtx,
233    ) -> Result<ToolOutcome, ToolError> {
234        let parsed: WebFetchArgs = serde_json::from_value(args)
235            .map_err(|e| ToolError::InvalidArguments(format!("Invalid WebFetch args: {}", e)))?;
236        let url = parsed.url.trim();
237        let mut current_url = url::Url::parse(url)
238            .map_err(|e| ToolError::InvalidArguments(format!("Invalid URL: {}", e)))?;
239
240        // Follow redirects manually: reqwest's auto-follow would re-resolve and
241        // connect to the redirect target WITHOUT re-running the SSRF guard, so
242        // `http://ok.example/ → 302 http://169.254.169.254/…` would leak. We
243        // validate-and-pin every hop instead. The WHOLE chain shares one 30s
244        // deadline so a server dribbling redirects can't stretch the request to
245        // MAX_REDIRECTS × 30s.
246        let fetch = async {
247            let mut hop_count = 0usize;
248            loop {
249                let scheme = current_url.scheme();
250                if scheme != "http" && scheme != "https" {
251                    return Err(ToolError::InvalidArguments(
252                        "Only http/https URLs are allowed".to_string(),
253                    ));
254                }
255
256                let (host, addrs) = Self::validate_and_resolve(&current_url).await?;
257
258                let client = reqwest::Client::builder()
259                    .timeout(Duration::from_secs(30))
260                    // Don't let reqwest silently follow to an unvalidated host.
261                    .redirect(reqwest::redirect::Policy::none())
262                    // Pin the connection to the exact addresses we just validated
263                    // so the guard and the socket use the same IP (no rebind).
264                    .resolve_to_addrs(&host, &addrs)
265                    .build()
266                    .map_err(|e| {
267                        ToolError::Execution(format!("Failed to build HTTP client: {}", e))
268                    })?;
269
270                let resp = client
271                    .get(current_url.clone())
272                    .send()
273                    .await
274                    .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?;
275
276                if resp.status().is_redirection() {
277                    let location = resp
278                        .headers()
279                        .get(reqwest::header::LOCATION)
280                        .and_then(|value| value.to_str().ok());
281                    if let Some(location) = location {
282                        let next = current_url.join(location).map_err(|e| {
283                            ToolError::Execution(format!("Invalid redirect Location: {}", e))
284                        })?;
285                        if current_url == next {
286                            return Ok(resp); // self-redirect; stop to avoid a loop
287                        }
288                        hop_count += 1;
289                        if hop_count > MAX_REDIRECTS {
290                            return Err(ToolError::Execution(format!(
291                                "Too many redirects (>{})",
292                                MAX_REDIRECTS
293                            )));
294                        }
295                        current_url = next;
296                        continue;
297                    }
298                }
299                return Ok(resp);
300            }
301        };
302        let response = tokio::time::timeout(Duration::from_secs(30), fetch)
303            .await
304            .map_err(|_| ToolError::Execution("WebFetch timed out after 30s".to_string()))??;
305
306        let status = response.status().as_u16();
307        let mut stream = response.bytes_stream();
308        let mut bytes = Vec::with_capacity(64 * 1024);
309        let mut response_truncated = false;
310        while let Some(chunk_result) = stream.next().await {
311            let chunk = chunk_result.map_err(|e| {
312                ToolError::Execution(format!("Failed reading response body: {}", e))
313            })?;
314            if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
315                let remaining = MAX_RESPONSE_BYTES.saturating_sub(bytes.len());
316                if remaining > 0 {
317                    bytes.extend_from_slice(&chunk[..remaining]);
318                }
319                response_truncated = true;
320                break;
321            }
322            bytes.extend_from_slice(&chunk);
323        }
324
325        let body = String::from_utf8_lossy(&bytes).to_string();
326
327        let text = Self::strip_html(&body)?;
328        let excerpt: String = text.chars().take(20_000).collect();
329
330        Ok(ToolOutcome::Completed(ToolResult {
331            success: true,
332            result: json!({
333                "url": parsed.url,
334                "status": status,
335                "prompt": parsed.prompt,
336                "content": excerpt,
337                "response_truncated": response_truncated,
338            })
339            .to_string(),
340            display_preference: Some("Collapsible".to_string()),
341            images: Vec::new(),
342        }))
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn strip_html_strips_scripts_styles_tags_and_collapses_whitespace() {
352        // Scripts and styles are dropped, tags are stripped, and runs of
353        // whitespace are collapsed to a single space, then trimmed.
354        let html = "<html><head><style>body{color:red}</style></head><body>\
355<script>alert(1)</script><h1>Title</h1><p>Hello   world</p></body></html>";
356        assert_eq!(WebFetchTool::strip_html(html).unwrap(), "Title Hello world");
357
358        // A second call exercises the already-initialized (cached) static regexes
359        // and must produce identical semantics.
360        let html2 = "<div>  <b>A</b>  <i>B</i>  </div>";
361        assert_eq!(WebFetchTool::strip_html(html2).unwrap(), "A B");
362    }
363
364    #[test]
365    fn disallowed_host_rejects_local_and_private_targets() {
366        assert!(WebFetchTool::is_disallowed_host("localhost"));
367        assert!(WebFetchTool::is_disallowed_host("api.localhost"));
368        assert!(WebFetchTool::is_disallowed_host("service.local"));
369        assert!(WebFetchTool::is_disallowed_host("127.0.0.1"));
370        assert!(WebFetchTool::is_disallowed_host("10.0.0.1"));
371        assert!(WebFetchTool::is_disallowed_host("192.168.1.1"));
372        assert!(WebFetchTool::is_disallowed_host("::1"));
373        assert!(!WebFetchTool::is_disallowed_host("example.com"));
374        assert!(!WebFetchTool::is_disallowed_host("8.8.8.8"));
375    }
376
377    #[test]
378    fn is_disallowed_ip_catches_ipv4_mapped_ipv6_and_cgnat() {
379        // IPv4-mapped IPv6 (::ffff:a.b.c.d) must be judged by IPv4 rules —
380        // otherwise loopback/link-local/private slip past the v6-only checks.
381        for mapped in [
382            "::ffff:127.0.0.1",
383            "::ffff:10.0.0.5",
384            "::ffff:192.168.1.1",
385            "::ffff:169.254.169.254", // cloud metadata
386        ] {
387            assert!(
388                WebFetchTool::is_disallowed_ip(mapped.parse().unwrap()),
389                "expected {mapped} to be disallowed"
390            );
391        }
392        // Deprecated IPv4-compatible (::a.b.c.d) and NAT64 (64:ff9b::/96) forms
393        // also embed a routable v4 target and must be canonicalized.
394        for embedded in [
395            "::169.254.169.254",
396            "::127.0.0.1",
397            "64:ff9b::169.254.169.254",
398            "64:ff9b::7f00:1", // 127.0.0.1
399        ] {
400            assert!(
401                WebFetchTool::is_disallowed_ip(embedded.parse().unwrap()),
402                "expected {embedded} to be disallowed"
403            );
404        }
405
406        // CGNAT 100.64.0.0/10 shared space.
407        assert!(WebFetchTool::is_disallowed_ip(
408            "100.64.0.1".parse().unwrap()
409        ));
410        assert!(WebFetchTool::is_disallowed_ip(
411            "100.127.255.255".parse().unwrap()
412        ));
413
414        // 0.0.0.0/8 — not just the exact 0.0.0.0.
415        assert!(WebFetchTool::is_disallowed_ip("0.0.0.0".parse().unwrap()));
416        assert!(WebFetchTool::is_disallowed_ip("0.1.2.3".parse().unwrap()));
417
418        // Boundaries just outside CGNAT and genuine public addresses stay allowed.
419        assert!(!WebFetchTool::is_disallowed_ip(
420            "100.63.255.255".parse().unwrap()
421        ));
422        assert!(!WebFetchTool::is_disallowed_ip(
423            "100.128.0.0".parse().unwrap()
424        ));
425        assert!(!WebFetchTool::is_disallowed_ip("8.8.8.8".parse().unwrap()));
426        assert!(!WebFetchTool::is_disallowed_ip(
427            "2606:4700:4700::1111".parse().unwrap()
428        ));
429        // Same reachable through the host-string path.
430        assert!(WebFetchTool::is_disallowed_host("::ffff:169.254.169.254"));
431        assert!(WebFetchTool::is_disallowed_host("100.64.0.1"));
432    }
433
434    #[test]
435    fn resolved_ips_include_disallowed_detects_any_private_or_loopback_ip() {
436        assert!(WebFetchTool::resolved_ips_include_disallowed(vec![
437            "8.8.8.8".parse::<IpAddr>().unwrap(),
438            "10.0.0.8".parse::<IpAddr>().unwrap(),
439        ]));
440        assert!(WebFetchTool::resolved_ips_include_disallowed(vec!["::1"
441            .parse::<IpAddr>()
442            .unwrap(),]));
443        assert!(!WebFetchTool::resolved_ips_include_disallowed(vec![
444            "1.1.1.1".parse::<IpAddr>().unwrap(),
445            "8.8.8.8".parse::<IpAddr>().unwrap(),
446        ]));
447    }
448
449    #[tokio::test]
450    async fn execute_rejects_non_http_schemes() {
451        let tool = WebFetchTool::new();
452        let err = tool
453            .invoke(
454                json!({
455                    "url": "file:///etc/passwd",
456                    "prompt": "read"
457                }),
458                ToolCtx::none("t"),
459            )
460            .await
461            .expect_err("non-http scheme should fail");
462
463        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("http/https")));
464    }
465
466    #[tokio::test]
467    async fn execute_rejects_restricted_hosts_before_network_call() {
468        let tool = WebFetchTool::new();
469        let err = tool
470            .invoke(
471                json!({
472                    "url": "http://localhost:8080",
473                    "prompt": "read"
474                }),
475                ToolCtx::none("t"),
476            )
477            .await
478            .expect_err("localhost should be blocked");
479
480        assert!(matches!(err, ToolError::Execution(msg) if msg.contains("restricted host")));
481    }
482}