use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use lru::LruCache;
use openpaw_core::{Result, ToolCallOutcome, ToolCategory, ToolConcurrency, ToolSpec};
use serde::Deserialize;
use crate::web::preapproved;
struct CacheEntry {
content: String,
fetched_at: Instant,
}
static FETCH_CACHE: Mutex<Option<LruCache<String, CacheEntry>>> = Mutex::new(None);
const CACHE_TTL: Duration = Duration::from_secs(15 * 60);
const CACHE_CAPACITY: usize = 50;
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build shared reqwest client for web_fetch")
})
}
fn build_cache_key(url: &url::Url, max_chars: usize) -> String {
format!("{}|{}", url.as_str(), max_chars)
}
#[derive(Deserialize)]
struct Args {
url: String,
#[serde(default = "default_max_chars")]
max_chars: usize,
}
fn default_max_chars() -> usize {
20_000
}
const TEST_ALLOW_LOOPBACK_ENV: &str = "OPENPAW_WEB_TEST_ALLOW_LOOPBACK";
pub(crate) fn loopback_test_allowance() -> bool {
std::env::var_os(TEST_ALLOW_LOOPBACK_ENV).is_some_and(|v| v == "1")
}
pub fn spec() -> ToolSpec {
ToolSpec {
name: "web_fetch".into(),
description: "Fetch a web page and return its content as Markdown (preserving headings, code blocks, and links).".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch"},
"max_chars": {"type": "integer", "description": "Max characters to return (default 20000)"}
},
"required": ["url"]
}),
category: Some(ToolCategory::Network),
concurrency: Some(ToolConcurrency::ParallelSafe),
is_destructive: Some(false),
..Default::default()
}
.with_description_fn(Arc::new(|input: &serde_json::Value| {
input
.get("url")
.and_then(|v| v.as_str())
.map(|u| format!("Fetching {u}"))
.unwrap_or_else(|| "Fetching".to_string())
}))
}
pub async fn run(_workspace: &Path, args: serde_json::Value) -> Result<ToolCallOutcome> {
let a: Args = match serde_json::from_value(args) {
Ok(a) => a,
Err(e) => return Ok(ToolCallOutcome::error(format!("web_fetch: bad args: {e}"))),
};
let mut current_url = match url::Url::parse(&a.url) {
Ok(u) => u,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_fetch: invalid URL: {e}"
)))
}
};
let allow_loopback = loopback_test_allowance();
if let Err(msg) = validate_url_ssrf(¤t_url, allow_loopback) {
return Ok(ToolCallOutcome::error(format!("web_fetch: {msg}")));
}
let is_preapproved = preapproved::is_preapproved_url(¤t_url);
let effective_max_chars = if is_preapproved {
a.max_chars.max(preapproved::PREAPPROVED_MAX_CHARS)
} else {
a.max_chars
};
let cache_key = build_cache_key(¤t_url, effective_max_chars);
if let Some(cached) = get_cached(&cache_key) {
return Ok(ToolCallOutcome::ok(cached));
}
if let Err(msg) = check_dns_rebinding(¤t_url, allow_loopback).await {
return Ok(ToolCallOutcome::error(format!("web_fetch: {msg}")));
}
let client = http_client();
const MAX_REDIRECTS: usize = 10;
let mut resp = match client
.get(current_url.as_str())
.header("User-Agent", "openpaw/0.1")
.send()
.await
{
Ok(r) => r,
Err(e) => return Ok(ToolCallOutcome::error(format!("web_fetch: {e}"))),
};
for _ in 0..MAX_REDIRECTS {
if !resp.status().is_redirection() {
break;
}
let Some(location) = resp.headers().get("location") else {
break;
};
let location_str = location.to_str().unwrap_or("");
let redirect_url = match current_url.join(location_str) {
Ok(u) => u,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_fetch: bad redirect URL: {e}"
)))
}
};
if let Err(msg) = validate_url_ssrf(&redirect_url, allow_loopback) {
return Ok(ToolCallOutcome::error(format!(
"web_fetch: redirect blocked: {msg}"
)));
}
if let Err(msg) = check_dns_rebinding(&redirect_url, allow_loopback).await {
return Ok(ToolCallOutcome::error(format!(
"web_fetch: redirect blocked: {msg}"
)));
}
current_url = redirect_url;
resp = match client
.get(current_url.as_str())
.header("User-Agent", "openpaw/0.1")
.send()
.await
{
Ok(r) => r,
Err(e) => {
return Ok(ToolCallOutcome::error(format!(
"web_fetch: redirect failed: {e}"
)))
}
};
}
if !resp.status().is_success() {
return Ok(ToolCallOutcome::error(format!("HTTP {}", resp.status())));
}
if let Some(len) = resp.content_length() {
if len > 5 * 1024 * 1024 {
return Ok(ToolCallOutcome::error(format!(
"response too large: Content-Length {} bytes (max 5MB)",
len
)));
}
}
let body = match resp.bytes().await {
Ok(bytes) => {
if bytes.len() > 5 * 1024 * 1024 {
return Ok(ToolCallOutcome::error(format!(
"response too large: {} bytes (max 5MB)",
bytes.len()
)));
}
String::from_utf8_lossy(&bytes).into_owned()
}
Err(e) => {
return Ok(ToolCallOutcome::error(format!("web_fetch body: {e}")));
}
};
let text = crate::web::html_to_md::html_to_markdown(&body);
let truncated = if text.chars().count() > effective_max_chars {
let t: String = text.chars().take(effective_max_chars).collect();
format!("{t}\n... (truncated at {} chars)", effective_max_chars)
} else {
text
};
put_cached(&cache_key, &truncated);
Ok(ToolCallOutcome::ok(truncated))
}
#[allow(clippy::incompatible_msrv)]
pub(crate) fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| (octets[0] == 100 && octets[1] >= 64 && octets[1] <= 127)
|| (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19))
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback()
|| v6.is_unicast_link_local()
|| v6.is_unique_local()
|| v6.is_unspecified()
{
return true;
}
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
return is_private_ip(std::net::IpAddr::V4(v4));
}
false
}
}
}
pub(crate) fn validate_url_ssrf(
url: &url::Url,
allow_loopback: bool,
) -> std::result::Result<(), String> {
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(format!(
"only http/https schemes are allowed, got '{scheme}'"
));
}
if let Some(host) = url.host() {
match host {
url::Host::Ipv4(ip) => {
if is_private_ip(std::net::IpAddr::V4(ip)) && !(allow_loopback && ip.is_loopback())
{
return Err(format!("refused to connect to private IP ({ip})"));
}
}
url::Host::Ipv6(ip) => {
if is_private_ip(std::net::IpAddr::V6(ip)) && !(allow_loopback && ip.is_loopback())
{
return Err(format!("refused to connect to private IPv6 ({ip})"));
}
}
url::Host::Domain(domain) => {
let lower = domain.to_lowercase();
let loopback_literal = lower == "127.0.0.1" || lower == "::1";
if lower == "localhost"
|| lower.ends_with(".localhost")
|| lower == "0.0.0.0"
|| (loopback_literal && !allow_loopback)
{
return Err(format!("refused to connect to localhost ({domain})"));
}
}
}
}
Ok(())
}
pub(crate) async fn check_dns_rebinding(
url: &url::Url,
allow_loopback: bool,
) -> std::result::Result<(), String> {
let is_domain = url
.host()
.map(|h| matches!(h, url::Host::Domain(_)))
.unwrap_or(false);
if !is_domain {
return Ok(());
}
let Some(host) = url.host_str() else {
return Ok(());
};
let port = url.port_or_known_default().unwrap_or(80);
let host_port = format!("{host}:{port}");
let resolved = tokio::net::lookup_host(&host_port).await;
match resolved {
Ok(addrs) => {
for addr in addrs {
if is_private_ip(addr.ip()) && !(allow_loopback && addr.ip().is_loopback()) {
return Err(format!(
"refused to connect to {host}: DNS resolved to private IP ({})",
addr.ip()
));
}
}
}
Err(_) => { }
}
Ok(())
}
fn get_cached(key: &str) -> Option<String> {
let mut guard = FETCH_CACHE.lock().ok()?;
let cache = guard
.get_or_insert_with(|| LruCache::new(std::num::NonZeroUsize::new(CACHE_CAPACITY).unwrap()));
let entry = cache.get(key)?;
if entry.fetched_at.elapsed() < CACHE_TTL {
Some(entry.content.clone())
} else {
cache.pop(key);
None
}
}
fn put_cached(key: &str, content: &str) {
if let Ok(mut guard) = FETCH_CACHE.lock() {
let cache = guard.get_or_insert_with(|| {
LruCache::new(std::num::NonZeroUsize::new(CACHE_CAPACITY).unwrap())
});
cache.put(
key.to_string(),
CacheEntry {
content: content.to_string(),
fetched_at: Instant::now(),
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_private_ip_rejects_loopback() {
assert!(is_private_ip("127.0.0.1".parse().unwrap()));
assert!(is_private_ip("::1".parse().unwrap()));
}
#[test]
fn is_private_ip_rejects_private_ranges() {
assert!(is_private_ip("192.168.1.1".parse().unwrap()));
assert!(is_private_ip("10.0.0.1".parse().unwrap()));
assert!(is_private_ip("172.16.0.1".parse().unwrap()));
}
#[test]
fn is_private_ip_rejects_ipv4_mapped_ipv6() {
assert!(is_private_ip("::ffff:127.0.0.1".parse().unwrap()));
assert!(is_private_ip("::ffff:192.168.1.1".parse().unwrap()));
}
#[test]
fn is_private_ip_allows_public() {
assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
}
#[test]
fn validate_url_ssrf_rejects_localhost() {
let url = url::Url::parse("http://localhost/").unwrap();
assert!(validate_url_ssrf(&url, false).is_err());
let url = url::Url::parse("http://127.0.0.1/").unwrap();
assert!(validate_url_ssrf(&url, false).is_err());
}
#[test]
fn loopback_allowance_still_blocks_other_private_ranges() {
let url = url::Url::parse("http://10.0.0.5/").unwrap();
assert!(validate_url_ssrf(&url, true).is_err());
let url = url::Url::parse("http://192.168.1.5/").unwrap();
assert!(validate_url_ssrf(&url, true).is_err());
let url = url::Url::parse("http://127.0.0.1/").unwrap();
assert!(validate_url_ssrf(&url, true).is_ok());
}
#[test]
fn validate_url_ssrf_rejects_non_http_schemes() {
let url = url::Url::parse("file:///etc/passwd").unwrap();
assert!(validate_url_ssrf(&url, false).is_err());
}
#[test]
fn validate_url_ssrf_allows_public_http() {
let url = url::Url::parse("https://example.com/").unwrap();
assert!(validate_url_ssrf(&url, false).is_ok());
}
#[test]
fn cache_key_distinguishes_max_chars() {
let url = url::Url::parse("https://example.com/").unwrap();
let small = build_cache_key(&url, 100);
let large = build_cache_key(&url, 20_000);
assert_ne!(small, large, "不同 max_chars 不得共享缓存条目");
assert_eq!(large, build_cache_key(&url, 20_000));
}
#[test]
fn http_client_is_shared() {
let a = http_client();
let b = http_client();
assert!(std::ptr::eq(a, b), "应复用同一个 Client 实例");
}
}