#![allow(clippy::unwrap_used, clippy::indexing_slicing)]
use std::net::IpAddr;
use entelix_core::AgentContext;
use entelix_core::tools::Tool;
use entelix_tools::{HostAllowlist, HttpFetchTool};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn allowlist_for(server: &MockServer) -> HostAllowlist {
let url = url::Url::parse(&server.uri()).unwrap();
let host = url.host_str().unwrap();
let mut allow = HostAllowlist::new();
if let Ok(ip) = host.parse::<IpAddr>() {
allow = allow.add_exact_ip(ip);
} else {
allow = allow.add_exact_host(host);
}
allow
}
#[tokio::test]
async fn happy_path_get_returns_status_and_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hello"))
.respond_with(ResponseTemplate::new(200).set_body_string("hi there"))
.mount(&server)
.await;
let tool = HttpFetchTool::builder()
.with_allowlist(allowlist_for(&server))
.build()
.unwrap();
let out = tool
.execute(
json!({"url": format!("{}/hello", server.uri())}),
&AgentContext::default(),
)
.await
.unwrap();
assert_eq!(out["status"], 200);
assert_eq!(out["body"], "hi there");
assert_eq!(out["truncated"], false);
}
#[tokio::test]
async fn body_cap_truncates_and_marks_truncated() {
let server = MockServer::start().await;
let big = "x".repeat(2048);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(big))
.mount(&server)
.await;
let tool = HttpFetchTool::builder()
.with_allowlist(allowlist_for(&server))
.with_max_response_bytes(512)
.build()
.unwrap();
let out = tool
.execute(json!({"url": server.uri()}), &AgentContext::default())
.await
.unwrap();
assert_eq!(out["truncated"], true);
assert!(
out["body"].as_str().unwrap().len() <= 512,
"body length: {}",
out["body"].as_str().unwrap().len()
);
}
#[tokio::test]
async fn host_outside_allowlist_is_rejected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_host("not-the-server.test"))
.build()
.unwrap();
let err = tool
.execute(json!({"url": server.uri()}), &AgentContext::default())
.await
.unwrap_err();
assert!(format!("{err}").contains("not on the allowlist"));
}
#[tokio::test]
async fn method_allowlist_rejects_post_by_default() {
let server = MockServer::start().await;
let tool = HttpFetchTool::builder()
.with_allowlist(allowlist_for(&server))
.build()
.unwrap();
let err = tool
.execute(
json!({"url": server.uri(), "method": "POST"}),
&AgentContext::default(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("not allowed"));
}
#[tokio::test]
async fn unsupported_scheme_is_rejected_before_network() {
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_host("example.com"))
.build()
.unwrap();
let err = tool
.execute(
json!({"url": "file:///etc/passwd"}),
&AgentContext::default(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("unsupported URL scheme"));
}
#[tokio::test]
async fn dns_rebinding_block_rejects_hostname_resolving_to_loopback() {
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_host("localhost"))
.build()
.unwrap();
let err = tool
.execute(
json!({"url": "http://localhost:9/probe"}),
&AgentContext::default(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("blocked") || msg.contains("SSRF") || msg.contains("network"),
"expected SSRF block error, got: {msg}"
);
}
#[tokio::test]
async fn explicit_ip_allow_round_trips_against_loopback_listener() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/probe"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let server_url = url::Url::parse(&server.uri()).unwrap();
let server_ip: IpAddr = server_url.host_str().unwrap().parse().unwrap();
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_ip(server_ip))
.build()
.unwrap();
let out = tool
.execute(
json!({"url": format!("{}/probe", server.uri())}),
&AgentContext::default(),
)
.await
.unwrap();
assert_eq!(out["status"], 204);
}
#[tokio::test]
async fn redirect_to_non_allowlisted_host_is_rejected() {
let permit = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", "http://forbidden.invalid/secret"),
)
.mount(&permit)
.await;
let permit_ip: IpAddr = url::Url::parse(&permit.uri())
.unwrap()
.host_str()
.unwrap()
.parse()
.unwrap();
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_ip(permit_ip))
.build()
.unwrap();
let err = tool
.execute(
json!({"url": format!("{}/start", permit.uri())}),
&AgentContext::default(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("error following redirect"),
"expected redirect-rejection error, got: {msg}"
);
}
#[tokio::test]
async fn redirect_within_allowlist_is_followed() {
let a = MockServer::start().await;
let b = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", format!("{}/end", b.uri())),
)
.mount(&a)
.await;
Mock::given(method("GET"))
.and(path("/end"))
.respond_with(ResponseTemplate::new(200).set_body_string("done"))
.mount(&b)
.await;
let a_ip: IpAddr = url::Url::parse(&a.uri())
.unwrap()
.host_str()
.unwrap()
.parse()
.unwrap();
let b_ip: IpAddr = url::Url::parse(&b.uri())
.unwrap()
.host_str()
.unwrap()
.parse()
.unwrap();
let tool = HttpFetchTool::builder()
.with_allowlist(HostAllowlist::new().add_exact_ip(a_ip).add_exact_ip(b_ip))
.build()
.unwrap();
let out = tool
.execute(
json!({"url": format!("{}/start", a.uri())}),
&AgentContext::default(),
)
.await
.unwrap();
assert_eq!(out["status"], 200);
assert_eq!(out["body"], "done");
assert!(out["final_url"].as_str().unwrap().ends_with("/end"));
}