//! http_request AI 工具 — 结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE) //! //! 设计目标:让 LLM 能查询外部 API(天气/汇率/文档/第三方服务),代替裸 run_command curl。 //! 相比 run_command:① 结构化输入(method/url/headers/body)避免 shell 注入;② 无 shell 进程 //! 开销;③ 显式 SSRF 防护(run_command 靠人工审批兜底,本工具因可被 LLM 自动触发 GET 需机器防护)。 //! //! ## 安全(SSRF 防护是核心) //! //! 1. **协议白名单**:仅 http/https,拒 file/ftp/gopher/data 等(reqwest 默认接 file://,故显式挡)。 //! 2. **私网 IP 黑名单**:拒 localhost/127.0.0.1/10.x/172.16-31.x/192.168.x/169.254.x/::1/fc00::/7。 //! 3. **DNS resolve 后校验 IP**(防 DNS rebinding):词法层校验主机名后,再解析其真实 IP 校验, //! 防 `evil.com` 解析到 127.0.0.1。 //! 4. **重定向限制 ≤3 跳**:每跳重新校验目标 host/IP(防 302 绕过到内网)。reqwest 默认跟 ≤10, //! 改用手动 redirect(Policy::none) + 自管循环。 //! 5. **超时**:默认 30s,clamp ≤60s(对齐 run_command 思路,但封顶更紧——HTTP 调用应快速返回)。 //! 6. **响应截断**:body 默认 ≤50KB(对齐 T-05),超长截断尾部保留 + truncated 标记。 //! 截断阈值可经 `max_response_chars` 参数覆盖(clamp [1KB, 10MB]);或经 `output_file` //! 将响应全量落盘(不截断),body 字段置 null + path/bytes_written 返回。 //! //! ## 风险分级 //! //! - GET → Medium:只读,但触发外发网络请求(数据可经 URL query 泄露),需可见。 //! - POST/PUT/PATCH/DELETE + body → High:写操作/副作用,须人工批准。 //! //! 注意:HTTP 无 cookie/认证复用(每次新 Client);headers 由 LLM 显式传(含 Authorization)。 //! 本工具不读环境变量 proxy(避免意外的内网 proxy SSRF 中转)— reqwest 默认尊重 HTTP_PROXY/HTTPS_PROXY //! 环境变量,桌面应用场景用户期望尊重系统代理,保留默认行为(用户主动配置的代理非 SSRF 攻击向量)。 use std::collections::HashMap; use std::net::IpAddr; use std::path::Path; use std::time::{Duration, Instant}; use serde_json::{json, Value}; use crate::state::AllowedDirs; /// 响应 body 截断默认上限(字节)。对齐 T-05 文档约定,防大响应撑爆 LLM context。 /// /// 2026-08-01:由硬编码改为默认值,LLM 可经 `max_response_chars` 参数覆盖(clamp [1024, 10MB])。 /// 无 `max_response_chars` 时回退此默认(保旧行为零变更)。 const DEFAULT_MAX_BODY_BYTES: usize = 50 * 1024; /// `max_response_chars` 参数 clamp 下限(字节)。防 LLM 传过小值截到无意义残片。 const MIN_MAX_RESPONSE_CHARS: usize = 1024; /// `max_response_chars` 参数 clamp 上限(字节,10MB)。防 LLM 传超大值撑爆 LLM context。 const MAX_MAX_RESPONSE_CHARS: usize = 10 * 1024 * 1024; /// 默认请求超时(秒)。LLM 可经 args timeout_secs 覆盖。 const DEFAULT_TIMEOUT_SECS: u64 = 30; /// 超时硬上限(秒)。防 LLM 传超大值冻结会话(对齐 run_command MAX 思路,封顶更紧)。 const MAX_TIMEOUT_SECS: u64 = 60; /// 重定向最大跳转数。防开放重定向被利用做 SSRF 中转(302 链绕过到内网)。 pub(crate) const MAX_REDIRECTS: usize = 3; /// body/headers 各自大小上限(防 LLM 传超大请求体)。 const MAX_REQUEST_BODY_BYTES: usize = 1 * 1024 * 1024; // 1MB /// 私网/保留 IP 段判定 — SSRF 防护核心。 /// /// 拒绝所有 RFC1918 私网 + 链路本地 + 环回 + 未指定 + IPv6 私网。词法层与 DNS 解析后都走此函数, /// 双重校验防 DNS rebinding(词法层过但解析后命中私网 IP 仍挡)。 /// /// 注:`is_global()` (std nightly-gated)在 stable 不可用,故手工列举保留段(覆盖 OWASP SSRF 常见面)。 pub(crate) fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => { let oct = v4.octets(); // 环回 127.0.0.0/8(整段,非仅 127.0.0.1) oct[0] == 127 // 0.0.0.0/8(本机/未指定,常被 SSRF 用于绕过) || oct[0] == 0 // 10.0.0.0/8(RFC1918 A 类私网) || oct[0] == 10 // 172.16.0.0/12(RFC1918 B 类私网:172.16.x - 172.31.x) || (oct[0] == 172 && (16..=31).contains(&oct[1])) // 192.168.0.0/16(RFC1918 C 类私网) || (oct[0] == 192 && oct[1] == 168) // 169.254.0.0/16(链路本地,云元数据服务 169.254.169.254 高危 SSRF 目标) || (oct[0] == 169 && oct[1] == 254) // 100.64.0.0/10(CGNAT 共享地址,部分云内网用) || (oct[0] == 100 && (64..=127).contains(&oct[1])) // 224.0.0.0/4(组播)/ 240.0.0.0/4(保留),非单播不应作请求目标 || oct[0] >= 224 } IpAddr::V6(v6) => { // ::1 环回 / :: 未指定 / fc00::/7 唯一本地(私网) / fe80::/10 链路本地 v6.is_loopback() || v6.is_unspecified() || (v6.octets()[0] & 0xFE) == 0xFC // fc00::/7(首字节高 7 位 1111110) || (v6.octets()[0] == 0xFE && (v6.octets()[1] & 0xC0) == 0x80) // fe80::/10 } } } /// 校验 URL:协议白名单 + 词法层 host 私网拦截。 /// /// 返回解析后的 (scheme, host, port)。DNS 解析后的 IP 校验在 `resolve_and_check_host` 做 /// (词法层只挡字面量 IP/localhost,DNS rebinding 靠 resolve 后校验补防)。 /// /// 安全顺序:① scheme 白名单 ② host 非空 ③ 字面量 IP 私网拦截 ④ localhost/.*local 等域名拦截。 pub(crate) fn validate_url(raw: &str) -> anyhow::Result<(String, String, u16)> { let parsed = reqwest::Url::parse(raw) .map_err(|e| anyhow::anyhow!("URL 解析失败: {} ({})", raw, e))?; let scheme = parsed.scheme().to_lowercase(); if scheme != "http" && scheme != "https" { anyhow::bail!( "不支持的协议 '{}':仅允许 http/https(拒 file/ftp/gopher/data 等)", scheme ); } let host = parsed.host_str() .ok_or_else(|| anyhow::anyhow!("URL 缺少 host: {}", raw))? .to_lowercase(); if host.is_empty() { anyhow::bail!("URL host 为空: {}", raw); } // 词法层:拒 localhost / *.localhost / IP 字面量私网(快路径挡常见 SSRF) if host == "localhost" || host.ends_with(".localhost") { anyhow::bail!("拒绝访问 localhost(SSRF 防护)"); } // 字面量 IP 校验(host 形如 "127.0.0.1" / "[::1]") let bare_host = host.trim_start_matches('[').trim_end_matches(']'); if let Ok(ip) = bare_host.parse::() { if is_private_ip(&ip) { anyhow::bail!("拒绝访问私网/保留 IP: {} (SSRF 防护)", ip); } } let port = parsed.port_or_known_default().unwrap_or(if scheme == "https" { 443 } else { 80 }); Ok((scheme, host, port)) } /// DNS 解析 host 后校验所有解析 IP 非私网(防 DNS rebinding)。 /// /// 词法层只挡字面量 IP;攻击者注册 `evil.com` 解析到 127.0.0.1 时词法层过,必须 resolve 后再校验。 /// 多 A 记录全部校验(任一私网即拒,防混合记录绕过)。 /// /// 注:用 blocking resolve(tokio::net 已在 runtime,但单次 resolve 短且同步 DNS 系统调用, /// spawn_blocking 会增加调度开销)。直接用 tokio::net::lookup_host 异步解析。 pub(crate) async fn resolve_and_check_host(host: &str, port: u16) -> anyhow::Result> { // lookup_host 需 host:port 形式;host 可能含 IPv6 字面量,SocketAddr::to_string 会自动加 [] let target = format!("{}:{}", host, port); let addrs: Vec = tokio::net::lookup_host(target) .await .map_err(|e| anyhow::anyhow!("DNS 解析失败 {}: {}", host, e))? .collect(); if addrs.is_empty() { anyhow::bail!("DNS 解析无结果: {}", host); } for addr in &addrs { let ip = addr.ip(); if is_private_ip(&ip) { anyhow::bail!( "拒绝访问: {} 解析到私网/保留 IP {} (SSRF 防护,DNS rebinding 拦截)", host, ip ); } } Ok(addrs) } /// 构建限制重定向的 reqwest Client(手动接管重定向以每跳校验 SSRF)。 /// /// reqwest 默认 follow ≤10 重定向且不暴露每跳 URL 校验钩子,故 Policy::none 关闭自动跟随, /// 在 execute_with_redirects 循环里逐跳校验 Location。 pub(crate) fn build_client(timeout: Duration) -> anyhow::Result { reqwest::Client::builder() .timeout(timeout) .connect_timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) // 手动接管重定向(每跳 SSRF 校验) .build() .map_err(|e| anyhow::anyhow!("HTTP client 构建失败: {}", e)) } /// 执行请求 + 手动重定向循环(每跳 SSRF 校验 Location)。 /// /// 返回最终响应(reqwest::Response)。重定向链每跳重新 validate_url + resolve_and_check_host, /// 防 302 Location: http://127.0.0.1/ 绕过初始 URL 校验。 pub(crate) async fn execute_with_redirects( client: &reqwest::Client, method: reqwest::Method, initial_url: String, headers: &HashMap, body: &Option, max_redirects: usize, ) -> anyhow::Result { let mut current_url = initial_url; let mut hops = 0usize; loop { let resp = build_request(client, method.clone(), ¤t_url, headers, body)? .send() .await .map_err(|e| anyhow::anyhow!("请求失败 {}: {}", current_url, e))?; if resp.status().is_redirection() { hops += 1; if hops > max_redirects { anyhow::bail!( "重定向超过 {} 跳上限(SSRF 防护),最后状态: {}", max_redirects, resp.status() ); } let location = resp.headers().get(reqwest::header::LOCATION) .and_then(|v| v.to_str().ok()) .ok_or_else(|| anyhow::anyhow!("重定向 {} 缺 Location 头", resp.status()))?; // 相对 Location 拼 base(current_url);绝对 Location 直接用 let next = if location.starts_with("http://") || location.starts_with("https://") { location.to_string() } else { let base = reqwest::Url::parse(¤t_url) .map_err(|e| anyhow::anyhow!("当前 URL 解析失败({}): {}", current_url, e))?; base.join(location) .map_err(|e| anyhow::anyhow!("重定向 Location 拼接失败({}): {}", location, e))? .to_string() }; // 每跳重新走完整 SSRF 校验(协议 + 词法 + DNS 解析 IP) let (_scheme, host, port) = validate_url(&next)?; resolve_and_check_host(&host, port).await?; current_url = next; continue; // 重试同 method?标准 301/302/303 改 GET,307/308 保 method。 // 为简化 + 安全(POST→GET 跟随会丢 body 改语义,易致 SSRF 探测), // 本工具统一保 method 跟随(对 303 严格不符 RFC,但安全更稳;正常 API 重定向少用 303)。 } return Ok(resp); } } /// 构建单个 reqwest::RequestBuilder(共享 method/url/headers/body 构建逻辑,重定向循环复用)。 fn build_request( client: &reqwest::Client, method: reqwest::Method, url: &str, headers: &HashMap, body: &Option, ) -> anyhow::Result { let mut req = client.request(method, url); for (k, v) in headers { // header 名/值用 HeaderName/HeaderValue 校验,非法字符直接报错(不静默丢) let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()) .map_err(|e| anyhow::anyhow!("非法 header 名 '{}': {}", k, e))?; let value = reqwest::header::HeaderValue::from_str(v) .map_err(|e| anyhow::anyhow!("header '{}' 值含非法字符: {}", k, e))?; req = req.header(name, value); } if let Some(b) = body { // body 大小校验(防 LLM 传超大请求体) if b.len() > MAX_REQUEST_BODY_BYTES { anyhow::bail!("请求体超过 {} 字节上限", MAX_REQUEST_BODY_BYTES); } req = req.body(b.clone()); } Ok(req) } /// http_request 工具 handler 入口(供 tool_registry.rs register 调用)。 /// /// 参数: /// - method: GET/POST/PUT/PATCH/DELETE(默认 GET,非法值报错) /// - url: 必填,http/https /// - headers: map,可选 /// - body: string,可选(GET 通常不传;POST/PUT/PATCH 传) /// - timeout_secs: 默认 30,clamp ≤60 /// - parse: "json"|"text"|"auto"(默认 auto),控制 body 解析展示 /// - output_file: string 可选,响应 body 写入指定文件路径(而非返回 JSON body), /// 适合大响应。路径走工具文件路径校验(validate_path + resolve_workspace_path_with_allowed /// + parent 授权目录校验,与 write_file 同源),全量写不截断。 /// - max_response_chars: integer 可选,默认 50KB(51200),clamp [1024, 10MB]。 /// body 截断阈值,替代硬编码上限。无 output_file 时控制返回 body 的截断;有 output_file 时不截断。 /// /// `allowed`:授权目录快照(由 handler 闭包 read lock clone 传入),仅 output_file 落盘路径校验用。 /// 无 output_file 时路径校验不触发,allowed 可为空快照(默认)。 /// /// 返回 {status, status_text, headers, body, elapsed_ms, truncated, url(最终重定向后)}。 /// 有 output_file 时:body 字段置 null(已落盘),增 path/bytes_written,且 truncated=false(全量写)。 pub(crate) async fn execute_http_request(args: Value, allowed: &AllowedDirs) -> anyhow::Result { // ── 参数解析 ── let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_uppercase(); let method = match method_str.as_str() { "GET" => reqwest::Method::GET, "POST" => reqwest::Method::POST, "PUT" => reqwest::Method::PUT, "PATCH" => reqwest::Method::PATCH, "DELETE" => reqwest::Method::DELETE, other => anyhow::bail!("不支持的 method '{}'(仅 GET/POST/PUT/PATCH/DELETE)", other), }; let url_raw = args.get("url").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))? .trim() .to_string(); if url_raw.is_empty() { anyhow::bail!("url 不能为空"); } // headers: map。LLM 可能传非 string 值(数字/bool),尽力转 string 容错。 let headers: HashMap = match args.get("headers") { Some(Value::Object(m)) => m.iter() .filter_map(|(k, v)| { let s = match v { Value::String(s) => s.clone(), other => other.to_string(), }; Some((k.clone(), s)) }) .collect(), _ => HashMap::new(), }; // body: 仅 string(LLM 传 JSON 字符串原样发,不替它序列化 — 透明转发,headers Content-Type 由 LLM 定) let body = args.get("body").and_then(|v| v.as_str()).map(|s| s.to_string()); let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_SECS) .min(MAX_TIMEOUT_SECS) .max(1); // clamp [1, 60] let parse_mode = args.get("parse").and_then(|v| v.as_str()).unwrap_or("auto").to_lowercase(); // output_file:响应 body 落盘路径(可选)。存在时走文件路径校验 + 全量写,不返回 body。 let output_file = args.get("output_file").and_then(|v| v.as_str()).map(|s| s.to_string()); // max_response_chars:body 截断阈值(字节)。默认 DEFAULT_MAX_BODY_BYTES,clamp [1024, 10MB]。 let max_response_chars = args.get("max_response_chars").and_then(|v| v.as_u64()) .map(|n| (n as usize).clamp(MIN_MAX_RESPONSE_CHARS, MAX_MAX_RESPONSE_CHARS)) .unwrap_or(DEFAULT_MAX_BODY_BYTES); // output_file 路径校验(若提供):validate_path + workspace 授权 + symlink 逃逸 + // parent 授权目录(落盘写需要,对齐 write_file FR-S8)。落盘时再次 resolve(复用同一校验)。 if let Some(of) = &output_file { if of.trim().is_empty() { anyhow::bail!("output_file 不能为空"); } let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?; if let Some(parent) = Path::new(&resolved).parent() { if !allowed.is_authorized(parent) { anyhow::bail!("禁止在项目目录之外写入文件"); } } } // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ── let (scheme, host, port) = validate_url(&url_raw)?; resolve_and_check_host(&host, port).await?; // ── 构建限制 client + 执行(手动重定向循环) ── let client = build_client(Duration::from_secs(timeout_secs))?; let started = Instant::now(); let resp = execute_with_redirects( &client, method, url_raw.clone(), &headers, &body, MAX_REDIRECTS, ).await?; let elapsed_ms = started.elapsed().as_millis() as u64; // ── 响应组装 ── let status = resp.status().as_u16(); let status_text = resp.status().canonical_reason().unwrap_or("").to_string(); let final_url = resp.url().to_string(); // headers 提取(多值合并逗号分隔) let mut resp_headers: HashMap = HashMap::new(); for (k, v) in resp.headers().iter() { let key = k.as_str().to_string(); let val = v.to_str().unwrap_or("").to_string(); resp_headers.entry(key) .and_modify(|existing| { existing.push_str(", "); existing.push_str(&val); }) .or_insert(val); } // body 读取 let full_body = resp.bytes().await .map_err(|e| anyhow::anyhow!("读取响应 body 失败: {}", e))?; let total_bytes = full_body.len(); // ── output_file 分支:全量写盘,不截断,不返回 body ── if let Some(of) = &output_file { let resolved = super::tool_registry::resolve_workspace_path_with_allowed(of, allowed)?; let path = resolved.to_str() .ok_or_else(|| anyhow::anyhow!("output_file 路径含非法字符"))? .to_string(); let target = Path::new(&path); // 父目录授权(对齐 write_file FR-S8)+ 自动创建(create_dir_all) if let Some(parent) = target.parent() { if !allowed.is_authorized(parent) { anyhow::bail!("禁止在项目目录之外写入文件"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建输出目录失败: {}", e))?; } // 全量写(原子:tmp→rename,避免写一半崩溃留半成品,与 write_file 同思路) let tmp = format!("{}.tmp-http", path); tokio::fs::write(&tmp, &full_body).await .map_err(|e| { let _ = std::fs::remove_file(&tmp); anyhow::anyhow!("写入 output_file 失败: {}", e) })?; tokio::fs::rename(&tmp, &path).await .map_err(|e| { let _ = std::fs::remove_file(&tmp); anyhow::anyhow!("output_file 原子重命名失败: {}", e) })?; return Ok(json!({ "method": method_str, "url": final_url, "status": status, "status_text": status_text, "scheme": scheme, "headers": resp_headers, "body": null, "path": path, "bytes_written": total_bytes, "truncated": false, "elapsed_ms": elapsed_ms, })); } // ── 默认分支:截断 + parse 格式化 + 返回 body ── let (body_text, truncated) = truncate_body(&full_body, max_response_chars); // parse 处理:json → pretty;auto → 尝试 json 失败回退 text;二进制(含 \0)跳过 let parsed_body = format_body(&body_text, total_bytes, &parse_mode, &resp_headers); Ok(json!({ "method": method_str, "url": final_url, "status": status, "status_text": status_text, "scheme": scheme, "headers": resp_headers, "body": parsed_body, "body_bytes": total_bytes, "truncated": truncated, "elapsed_ms": elapsed_ms, })) } /// 截断 body 到 max 字节(尾部保留报错堆栈 + 截断标记)。 /// 按 char 边界切(防切多字节 UTF-8 中间)。返回 (截断后 String, 是否截断)。 /// /// 二进制检测(含 \0 字节)优先于长度判定:无论是否超长,二进制响应一律用占位串替代, /// 避免 String::from_utf8_lossy 把非法字节全转 U+FFFD 撑爆 LLM context。 fn truncate_body(bytes: &[u8], max: usize) -> (String, bool) { if bytes.contains(&0u8) { return ( format!("[二进制响应 {} 字节,跳过文本展示]", bytes.len()), true, ); } if bytes.len() <= max { // 全量;非 UTF-8 容错降级(String::from_utf8_lossy 会替换非法字节为 U+FFFD) let s = String::from_utf8_lossy(bytes).into_owned(); return (s, false); } let head = &bytes[..max]; let s = String::from_utf8_lossy(head).into_owned(); // lossy 可能在截断处插入 U+FFFD(切到多字节中间),接受此降级 + 截断标记 ( format!("{}... [body 已截断,原始 {} 字节,仅保留前 {} 字节]", s, bytes.len(), head.len()), true, ) } /// 按 parse 模式格式化 body 文本。 /// - json: 尝试 serde_json::to_string_pretty,失败回退原文 /// - text: 原样 /// - auto: 看 Content-Type 含 json → 走 json;否则 text /// 二进制已被 truncate_body 处理为占位串,此处原样返回。 fn format_body( body_text: &str, total_bytes: usize, parse_mode: &str, headers: &HashMap, ) -> String { // 二进制占位串特征(由 truncate_body 注入):直接返回不解析 if body_text.starts_with("[二进制响应") { return body_text.to_string(); } let want_json = match parse_mode { "json" => true, "text" => false, _ => { // auto:Content-Type 含 json → 走 json 解析 let ct = headers.get("content-type") .or_else(|| headers.get("Content-Type")) .map(|s| s.to_lowercase()) .unwrap_or_default(); ct.contains("json") } }; if want_json { if let Ok(v) = serde_json::from_str::(body_text) { return serde_json::to_string_pretty(&v).unwrap_or_else(|_| body_text.to_string()); } // 解析失败:零字节文本但非合法 JSON(如 HTML 含 标签),回退原文 + 提示 if total_bytes > 0 { return format!("{}\n[注:期望 JSON 但解析失败,原样展示]", body_text); } } body_text.to_string() } // ============================================================ // 单元测试 // // 覆盖 SSRF 防护纯函数(is_private_ip / validate_url)+ handler 集成路径。 // 无 mock 库(mockito 未引入 workspace),采用两类测试: // ① 纯函数(is_private_ip/validate_url):零网络依赖,确定性。 // ② handler 集成:对 example.com(GET 200 稳定公共端点)做真实请求,验证 happy path; // SSRF 拒绝路径(localhost/127.0.0.1)走 validate_url 纯函数断言(handler 集成层 // 已由纯函数覆盖,无需真发请求触发拒绝)。超时/截断靠纯函数逻辑断言 + 参数边界。 // 真实网络测试 #[ignore](CI 无网时跳过,本地 `cargo test -- --ignored` 手动跑)。 // ============================================================ #[cfg(test)] mod tests { use super::*; // ── is_private_ip:IPv4 各保留段 ── #[test] fn test_is_private_ip_v4_loopback() { assert!(is_private_ip(&"127.0.0.1".parse().unwrap())); assert!(is_private_ip(&"127.255.255.254".parse().unwrap())); // 整段 127/8 } #[test] fn test_is_private_ip_v4_rfc1918() { assert!(is_private_ip(&"10.0.0.1".parse().unwrap())); assert!(is_private_ip(&"10.255.255.255".parse().unwrap())); assert!(is_private_ip(&"172.16.0.1".parse().unwrap())); assert!(is_private_ip(&"172.31.255.254".parse().unwrap())); assert!(is_private_ip(&"192.168.1.1".parse().unwrap())); assert!(is_private_ip(&"192.168.0.0".parse().unwrap())); } #[test] fn test_is_private_ip_v4_link_local_and_metadata() { // 169.254.169.254:云元数据服务,SSRF 头号目标 assert!(is_private_ip(&"169.254.169.254".parse().unwrap())); assert!(is_private_ip(&"169.254.0.1".parse().unwrap())); } #[test] fn test_is_private_ip_v4_cgnat_and_zero_and_multicast() { assert!(is_private_ip(&"100.64.0.1".parse().unwrap())); // CGNAT 100.64/10 assert!(is_private_ip(&"0.0.0.0".parse().unwrap())); // 未指定 assert!(is_private_ip(&"224.0.0.1".parse().unwrap())); // 组播 assert!(is_private_ip(&"240.0.0.1".parse().unwrap())); // 保留 } #[test] fn test_is_private_ip_v4_public_not_matched() { // 172.15 / 172.32 边界外不算私网 assert!(!is_private_ip(&"172.15.0.1".parse().unwrap())); assert!(!is_private_ip(&"172.32.0.1".parse().unwrap())); // 公网示例(8.8.8.8 / 1.1.1.1) assert!(!is_private_ip(&"8.8.8.8".parse().unwrap())); assert!(!is_private_ip(&"1.1.1.1".parse().unwrap())); } #[test] fn test_is_private_ip_v6_special() { assert!(is_private_ip(&"::1".parse().unwrap())); // 环回 assert!(is_private_ip(&"::".parse().unwrap())); // 未指定 assert!(is_private_ip(&"fe80::1".parse().unwrap())); // 链路本地 assert!(is_private_ip(&"fc00::1".parse().unwrap())); // 唯一本地 assert!(is_private_ip(&"fd00::1".parse().unwrap())); // 唯一本地 } // ── validate_url:协议白名单 + 词法层拦截 ── #[test] fn test_validate_url_https_public_ok() { let (scheme, host, port) = validate_url("https://example.com/path").unwrap(); assert_eq!(scheme, "https"); assert_eq!(host, "example.com"); assert_eq!(port, 443); } #[test] fn test_validate_url_http_default_port() { let (scheme, _, port) = validate_url("http://example.com").unwrap(); assert_eq!(scheme, "http"); assert_eq!(port, 80); } #[test] fn test_validate_url_rejects_non_http_schemes() { assert!(validate_url("file:///etc/passwd").is_err()); assert!(validate_url("ftp://example.com").is_err()); assert!(validate_url("gopher://example.com").is_err()); assert!(validate_url("data:text/plain,hello").is_err()); } #[test] fn test_validate_url_rejects_localhost() { assert!(validate_url("http://localhost/admin").is_err()); assert!(validate_url("http://sub.localhost/x").is_err()); } #[test] fn test_validate_url_rejects_literal_private_ip() { assert!(validate_url("http://127.0.0.1/").is_err()); assert!(validate_url("http://10.0.0.1/").is_err()); assert!(validate_url("http://192.168.1.1/").is_err()); assert!(validate_url("http://172.16.0.1/").is_err()); assert!(validate_url("http://169.254.169.254/").is_err()); // IPv6 环回字面量 assert!(validate_url("http://[::1]/").is_err()); } #[test] fn test_validate_url_case_insensitive_scheme() { let (scheme, _, _) = validate_url("HTTPS://Example.COM").unwrap(); assert_eq!(scheme, "https"); } // ── truncate_body:截断 + 二进制跳过 ── #[test] fn test_truncate_body_under_limit() { let s = b"hello world".to_vec(); let (out, trunc) = truncate_body(&s, 1024); assert!(!trunc); assert_eq!(out, "hello world"); } #[test] fn test_truncate_body_over_limit_text() { let big = "a".repeat(60_000); let (out, trunc) = truncate_body(big.as_bytes(), 1024); assert!(trunc); assert!(out.contains("[body 已截断")); assert!(out.contains("原始 60000 字节")); } #[test] fn test_truncate_body_binary_nul_detected() { // 含 \0 字节:视为二进制,不 lossy 展开大段 U+FFFD let mut bin = vec![0x41u8; 100]; // 'A' x100 bin.extend_from_slice(&[0u8; 50]); let (out, trunc) = truncate_body(&bin, 1024); assert!(trunc); assert!(out.starts_with("[二进制响应")); } // ── format_body:json/text/auto 解析 ── #[test] fn test_format_body_json_pretty() { let body = r#"{"a":1,"b":[2,3]}"#; let out = format_body(body, body.len(), "json", &HashMap::new()); assert!(out.contains("\"a\": 1")); assert!(out.contains("\n")); // pretty 多行 } #[test] fn test_format_body_text_passthrough() { let body = "raw"; let out = format_body(body, body.len(), "text", &HashMap::new()); assert_eq!(out, body); } #[test] fn test_format_body_auto_detects_json_via_content_type() { let body = r#"{"x":5}"#; let mut h = HashMap::new(); h.insert("content-type".to_string(), "application/json; charset=utf-8".to_string()); let out = format_body(body, body.len(), "auto", &h); assert!(out.contains("\"x\": 5")); } #[test] fn test_format_body_auto_text_when_not_json_ct() { let body = "plain text"; let mut h = HashMap::new(); h.insert("content-type".to_string(), "text/html".to_string()); let out = format_body(body, body.len(), "auto", &h); assert_eq!(out, "plain text"); } #[test] fn test_format_body_json_parse_fail_fallback() { let body = "{not valid json"; let out = format_body(body, body.len(), "json", &HashMap::new()); assert!(out.contains("[注:期望 JSON 但解析失败")); } // ── handler 参数边界(method/timeout/missing url) ── #[tokio::test] async fn test_handler_missing_url_errors() { let args = json!({ "method": "GET" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("缺少 url")); } #[tokio::test] async fn test_handler_invalid_method_errors() { let args = json!({ "method": "TRACE", "url": "https://example.com/" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("不支持的 method")); } #[tokio::test] async fn test_handler_rejects_localhost_url() { // SSRF 防护:handler 入口 validate_url 拒 localhost,不发请求 let args = json!({ "method": "GET", "url": "http://localhost:8080/admin" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_private_ip_url() { let args = json!({ "method": "GET", "url": "http://169.254.169.254/latest/meta-data/" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_non_http_scheme() { let args = json!({ "url": "file:///etc/passwd" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("协议")); } #[tokio::test] async fn test_handler_timeout_clamped() { // timeout_secs=9999 应 clamp 到 60;此处只验证不因超大值 panic(handler 内会走网络, // 故本测试不期望成功完成——用 localhost 触发 SSRF 早期拒绝,验证 clamp 在拒绝前不 panic) let args = json!({ "url": "http://localhost/", "timeout_secs": 9999 }); let _ = execute_http_request(args, &AllowedDirs::default()).await; // 不 panic 即通过 } // ── output_file:路径校验拒绝(提前在 SSRF/请求前触发,不发网络) ── #[tokio::test] async fn test_output_file_rejects_path_traversal() { // output_file 含 .. → validate_path 拒绝(在发请求前,故无需真实网络) let args = json!({ "url": "https://example.com/", "output_file": "../escape.txt" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("路径遍历")); } #[tokio::test] async fn test_output_file_rejects_empty() { let args = json!({ "url": "https://example.com/", "output_file": " " }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); assert!(format!("{}", err).contains("output_file 不能为空")); } #[tokio::test] async fn test_output_file_rejects_relative_without_allowed() { // 相对路径 + 空白名单 → resolve_workspace_path_with_allowed 引导报错(在发请求前) let args = json!({ "url": "https://example.com/", "output_file": "out.txt" }); let err = execute_http_request(args, &AllowedDirs::default()).await.unwrap_err(); // 无授权目录时 resolve 报"请先绑定项目目录" let msg = format!("{}", err); assert!(msg.contains("绑定项目目录") || msg.contains("授权目录"), "期望授权引导错误,实际: {}", msg); } // ── 真实网络集成(#[ignore]:CI 无网时跳过,本地手跑) ── #[tokio::test] #[ignore = "需真实网络(example.com),CI 无网时跳过:cargo test -- --ignored"] async fn integration_get_example_com_200() { let args = json!({ "method": "GET", "url": "https://example.com/" }); let result = execute_http_request(args, &AllowedDirs::default()).await.expect("GET example.com 应成功"); let status = result["status"].as_u64().unwrap(); assert!(status == 200, "期望 200,实际 {}", status); assert!(result["body"].as_str().unwrap_or("").contains("Example Domain")); assert_eq!(result["truncated"], false); assert!(result["elapsed_ms"].as_u64().unwrap_or(0) > 0); } #[tokio::test] #[ignore = "需真实网络,本地手跑"] async fn integration_max_response_chars_truncates() { // 默认 50KB 不截断 example.com(<1KB);max_response_chars=100 强制截断验证参数生效 let args = json!({ "url": "https://example.com/", "max_response_chars": 100 }); let result = execute_http_request(args, &AllowedDirs::default()).await .expect("GET example.com 应成功"); assert_eq!(result["truncated"], true, "应被 max_response_chars 截断"); // 默认(无 max_response_chars)不截断 let args2 = json!({ "url": "https://example.com/" }); let result2 = execute_http_request(args2, &AllowedDirs::default()).await.unwrap(); assert_eq!(result2["truncated"], false); } #[tokio::test] #[ignore = "需真实网络 + 临时目录,本地手跑"] async fn integration_output_file_writes_body() { // output_file 落盘:body 应为 null,path/bytes_written 存在,truncated=false。 // 路径校验需授权目录,故构建含 temp_dir 的 AllowedDirs(对齐 write_file 测试思路)。 let dir = std::env::temp_dir(); let out_path = dir.join("devflow_http_test_output.html"); let out_str = out_path.to_string_lossy().to_string(); let mut allowed = AllowedDirs::default(); allowed.persistent.insert(dir.clone()); let args = json!({ "url": "https://example.com/", "output_file": out_str }); let result = execute_http_request(args, &allowed).await .expect("GET example.com 应成功"); assert_eq!(result["status"], 200); assert_eq!(result["body"], serde_json::Value::Null, "body 应置 null(已落盘)"); assert_eq!(result["truncated"], false, "落盘不截断"); let bytes_written = result["bytes_written"].as_u64().unwrap(); assert!(bytes_written > 0, "应写入非零字节"); assert_eq!(result["path"], out_str); // 校验文件内容确实是 example.com 响应 let written = std::fs::read_to_string(&out_path).unwrap(); assert!(written.contains("Example Domain")); let _ = std::fs::remove_file(&out_path); } #[tokio::test] #[ignore = "需真实网络,本地手跑"] async fn integration_get_follows_redirect() { // httpbin.org/redirect-to 重定向到 example.com,验证重定向链 + SSRF 每跳校验通过 let target = urlencoding("https://example.com/"); let args = json!({ "url": format!("https://httpbin.org/redirect-to?url={}", target) }); let result = execute_http_request(args, &AllowedDirs::default()).await.expect("重定向链应成功"); let status = result["status"].as_u64().unwrap_or(0); // 最终落地 200(经重定向) assert!(status >= 200 && status < 400, "重定向后状态: {}", status); assert!(result["url"].as_str().unwrap_or("").contains("example.com")); } /// 简单 URL 编码(测试用,避免引入 urlencoding crate)。仅编码必要字符。 fn urlencoding(s: &str) -> String { s.chars().map(|c| match c { ':' => "%3A".to_string(), '/' => "%2F".to_string(), _ => c.to_string(), }).collect() } }