use super::Tool;
use crate::config::is_local_endpoint;
use crate::safety::{is_private_or_internal, PinnedDnsResolver};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
pub struct HttpRequest;
#[derive(Debug, Clone, Copy)]
struct HttpTargetPolicy {
allow_localhost: bool,
allow_private: bool,
}
#[async_trait]
impl Tool for HttpRequest {
fn name(&self) -> &str {
"http_request"
}
fn description(&self) -> &str {
"Make HTTP requests to APIs or web endpoints. Supports GET, POST, PUT, DELETE methods. \
Use for fetching documentation, calling APIs, or testing endpoints. \
Localhost/loopback URLs are allowed by default; set SELFWARE_ALLOW_PRIVATE_NETWORK=1 for private LAN hosts."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to request"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
"default": "GET",
"description": "HTTP method"
},
"headers": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Request headers"
},
"body": {
"type": "string",
"description": "Request body (for POST/PUT/PATCH)"
},
"timeout_secs": {
"type": "integer",
"default": 30,
"description": "Request timeout in seconds"
},
"follow_redirects": {
"type": "boolean",
"default": true,
"description": "Whether to follow redirects"
}
},
"required": ["url"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
url: String,
#[serde(default = "default_method")]
method: String,
#[serde(default)]
headers: HashMap<String, String>,
body: Option<String>,
#[serde(default = "default_timeout")]
timeout_secs: u64,
#[serde(default = "default_true")]
follow_redirects: bool,
}
fn default_method() -> String {
"GET".to_string()
}
fn default_timeout() -> u64 {
30
}
fn default_true() -> bool {
true
}
let mut args: Args = serde_json::from_value(args)?;
const MAX_TIMEOUT_SECS: u64 = 300;
args.timeout_secs = args.timeout_secs.min(MAX_TIMEOUT_SECS);
let url = reqwest::Url::parse(&args.url).context("Invalid URL")?;
let allow_private =
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1";
let policy = validate_http_request_target(&url, allow_private)?;
let builder = Client::builder()
.timeout(Duration::from_secs(args.timeout_secs))
.dns_resolver(Arc::new(PinnedDnsResolver::new(
policy.allow_private || policy.allow_localhost,
)));
if let Some(host) = url.host_str() {
if is_private_network_host(host) && policy.allow_private && !policy.allow_localhost {
tracing::warn!(
"Allowing request to private network (SELFWARE_ALLOW_PRIVATE_NETWORK=1): {}",
host
);
}
}
let client = builder
.redirect(if args.follow_redirects {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() > 10 {
return attempt.error("Too many redirects");
}
if let Some(host) = attempt.url().host_str().map(|h| h.to_owned()) {
if !policy.allow_private
&& !is_trusted_local_network_host(&host)
&& is_private_network_host(&host)
{
return attempt
.error("Blocked redirect to private/internal network address");
}
}
attempt.follow()
})
} else {
reqwest::redirect::Policy::none()
})
.build()
.context("Failed to build HTTP client")?;
let mut request = match args.method.to_uppercase().as_str() {
"GET" => client.get(&args.url),
"POST" => client.post(&args.url),
"PUT" => client.put(&args.url),
"DELETE" => client.delete(&args.url),
"PATCH" => client.patch(&args.url),
"HEAD" => client.head(&args.url),
_ => anyhow::bail!("Unsupported HTTP method: {}", args.method),
};
for (key, value) in &args.headers {
request = request.header(key, value);
}
if let Some(body) = args.body {
request = request.body(body);
}
let start = std::time::Instant::now();
let response = request
.send()
.await
.context("Failed to send HTTP request")?;
let duration_ms = start.elapsed().as_millis() as u64;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("Unknown");
let response_headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let content_type = response_headers
.get("content-type")
.cloned()
.unwrap_or_default();
let body = response
.text()
.await
.context("Failed to read response body")?;
let truncated = body.len() > 50000;
let body = if truncated {
let safe_truncate: String = body.chars().take(50000).collect();
format!(
"{}...[truncated, {} bytes total]",
safe_truncate,
body.len()
)
} else {
body
};
let body_json: Option<Value> = if content_type.contains("application/json") {
serde_json::from_str(&body).ok()
} else {
None
};
Ok(serde_json::json!({
"status": status,
"status_text": status_text,
"headers": response_headers,
"body": body,
"body_json": body_json,
"duration_ms": duration_ms,
"truncated": truncated
}))
}
}
fn is_private_network_host(host: &str) -> bool {
if host == "localhost" || host.ends_with(".localhost") {
return true;
}
let bare_host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = bare_host.parse::<IpAddr>() {
return is_private_or_internal(ip);
}
false
}
fn is_trusted_local_network_host(host: &str) -> bool {
let bare_host = host.trim_start_matches('[').trim_end_matches(']');
matches!(bare_host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0")
|| bare_host.ends_with(".localhost")
}
fn validate_http_request_target(
url: &reqwest::Url,
allow_private: bool,
) -> Result<HttpTargetPolicy> {
if url.scheme() != "http" && url.scheme() != "https" {
anyhow::bail!("Only HTTP and HTTPS URLs are allowed");
}
let allow_localhost = is_local_endpoint(url.as_str())
|| url.host_str().is_some_and(is_trusted_local_network_host);
if let Some(host) = url.host_str() {
if let Ok(ip) = host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
{
if is_private_or_internal(ip) && !(allow_private || allow_localhost) {
anyhow::bail!(
"Blocked request to private/internal network address: {}. \
Set SELFWARE_ALLOW_PRIVATE_NETWORK=1 to allow.",
host
);
}
}
}
Ok(HttpTargetPolicy {
allow_localhost,
allow_private,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_request_name() {
let tool = HttpRequest;
assert_eq!(tool.name(), "http_request");
}
#[test]
fn test_http_request_description() {
let tool = HttpRequest;
assert!(tool.description().contains("HTTP"));
assert!(tool.description().contains("API"));
}
#[test]
fn test_http_request_schema() {
let tool = HttpRequest;
let schema = tool.schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["url"].is_object());
assert!(schema["properties"]["method"].is_object());
assert!(schema["properties"]["headers"].is_object());
}
#[test]
fn test_http_request_schema_methods() {
let tool = HttpRequest;
let schema = tool.schema();
let methods = schema["properties"]["method"]["enum"].as_array().unwrap();
assert!(methods.contains(&serde_json::json!("GET")));
assert!(methods.contains(&serde_json::json!("POST")));
assert!(methods.contains(&serde_json::json!("PUT")));
assert!(methods.contains(&serde_json::json!("DELETE")));
}
#[tokio::test]
async fn test_http_request_invalid_url() {
let tool = HttpRequest;
let result = tool
.execute(serde_json::json!({
"url": "not-a-valid-url"
}))
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_http_request_invalid_scheme() {
let tool = HttpRequest;
let result = tool
.execute(serde_json::json!({
"url": "ftp://example.com/file"
}))
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("HTTP"));
}
#[tokio::test]
async fn test_http_request_invalid_method() {
let tool = HttpRequest;
let result = tool
.execute(serde_json::json!({
"url": "https://example.com",
"method": "INVALID"
}))
.await;
assert!(result.is_err());
}
#[test]
fn test_http_request_schema_required() {
let tool = HttpRequest;
let schema = tool.schema();
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("url")));
}
#[test]
fn test_http_request_schema_has_timeout() {
let tool = HttpRequest;
let schema = tool.schema();
assert!(schema["properties"]["timeout_secs"].is_object());
}
#[test]
fn test_http_request_schema_has_body() {
let tool = HttpRequest;
let schema = tool.schema();
assert!(schema["properties"]["body"].is_object());
}
#[tokio::test]
async fn test_http_request_missing_url() {
let tool = HttpRequest;
let result = tool.execute(serde_json::json!({})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_http_request_file_scheme() {
let tool = HttpRequest;
let result = tool
.execute(serde_json::json!({
"url": "file:///etc/passwd"
}))
.await;
assert!(result.is_err());
}
#[test]
fn test_validate_http_request_target_allows_localhost() {
let url = reqwest::Url::parse("http://localhost:8888/health").unwrap();
let policy = validate_http_request_target(&url, false).unwrap();
assert!(policy.allow_localhost);
assert!(!policy.allow_private);
}
#[test]
fn test_validate_http_request_target_blocks_private_lan_without_opt_in() {
let url = reqwest::Url::parse("http://192.168.1.10:8000/health").unwrap();
let error = validate_http_request_target(&url, false).unwrap_err();
assert!(error
.to_string()
.contains("Blocked request to private/internal network address"));
}
}