use super::*;
#[test]
fn test_is_domain_allowed_with_wildcard() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
assert!(is_domain_allowed("example.com", &config.allowed_domains));
assert!(is_domain_allowed("any.domain.anywhere", &config.allowed_domains));
}
#[test]
fn test_is_domain_allowed_exact_match() {
let config = HttpClientConfig {
allowed_domains: vec!["example.com".to_string(), "safe.io".to_string()],
..Default::default()
};
assert!(is_domain_allowed("example.com", &config.allowed_domains));
assert!(is_domain_allowed("safe.io", &config.allowed_domains));
assert!(!is_domain_allowed("other.com", &config.allowed_domains));
}
#[test]
fn test_is_domain_allowed_glob_pattern() {
let config = HttpClientConfig {
allowed_domains: vec!["*.example.com".to_string()],
..Default::default()
};
assert!(is_domain_allowed("api.example.com", &config.allowed_domains));
assert!(is_domain_allowed("sub.api.example.com", &config.allowed_domains));
assert!(!is_domain_allowed("example.com", &config.allowed_domains));
assert!(!is_domain_allowed("other.com", &config.allowed_domains));
}
#[tokio::test]
async fn test_validate_outbound_url_valid() {
let config = HttpClientConfig {
allowed_domains: vec!["example.com".to_string()],
..Default::default()
};
assert!(validate_outbound_url("https://example.com/api", &config).await.is_ok());
}
#[tokio::test]
async fn test_validate_outbound_url_invalid_domain() {
let config = HttpClientConfig {
allowed_domains: vec!["example.com".to_string()],
..Default::default()
};
assert!(validate_outbound_url("https://other.com/api", &config).await.is_err());
}
#[tokio::test]
async fn test_validate_outbound_url_blocks_private_ip() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
assert!(validate_outbound_url("http://127.0.0.1/api", &config).await.is_err());
assert!(validate_outbound_url("http://192.168.1.1/api", &config).await.is_err());
assert!(validate_outbound_url("http://10.0.0.1/api", &config).await.is_err());
}
#[tokio::test]
async fn test_validate_outbound_url_blocks_ipv6_loopback() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
assert!(validate_outbound_url("http://[::1]/api", &config).await.is_err());
}
#[tokio::test]
async fn test_validate_outbound_url_blocks_ipv6_link_local() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
assert!(validate_outbound_url("http://[fe80::1]/api", &config).await.is_err());
}
#[tokio::test]
async fn test_validate_outbound_url_allows_public_ip() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
assert!(validate_outbound_url("http://8.8.8.8/api", &config).await.is_ok());
}
#[tokio::test]
async fn test_validate_outbound_url_invalid_url() {
let config = HttpClientConfig::default();
assert!(validate_outbound_url("not a valid url", &config).await.is_err());
}
#[test]
fn test_default_allowlist_is_deny_by_default() {
let config = HttpClientConfig::default();
assert!(config.allowed_domains.is_empty());
assert!(!is_domain_allowed("example.com", &config.allowed_domains));
assert!(!is_domain_allowed("8.8.8.8", &config.allowed_domains));
}
#[tokio::test]
async fn test_deny_by_default_rejects_unlisted_domain() {
let config = HttpClientConfig::default();
let result = validate_outbound_url("https://example.com/api", &config).await;
assert!(
matches!(result, Err(FraiseQLError::Authorization { .. })),
"deny-by-default must reject an unlisted domain, got: {result:?}"
);
}
#[tokio::test]
async fn test_domain_resolving_to_private_ip_is_rejected() {
let config = HttpClientConfig {
allowed_domains: vec!["localhost".to_string()],
..Default::default()
};
let result = validate_outbound_url("http://localhost/api", &config).await;
assert!(
result.is_err(),
"a domain resolving to a private/loopback IP must be rejected: {result:?}"
);
}
#[tokio::test]
async fn test_built_client_disables_redirects() {
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", "http://169.254.169.254/"),
)
.mount(&server)
.await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap();
let resp = client.get(format!("{}/start", server.uri())).send().await.unwrap();
assert_eq!(
resp.status().as_u16(),
302,
"client must surface the 302 instead of following the redirect"
);
}
#[tokio::test]
async fn blocks_ipv4_mapped_and_unspecified_v6() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
for s in [
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"::ffff:a9fe:a9fe",
"::ffff:10.0.0.1",
"::ffff:192.168.1.1",
"::ffff:0.0.0.0",
"::",
] {
let url = format!("http://[{s}]/");
assert!(validate_outbound_url(&url, &config).await.is_err(), "must block {s}");
}
}
#[tokio::test]
async fn blocks_mapped_metadata_literal_through_the_url_path() {
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
for url in [
"http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/",
"http://[::ffff:127.0.0.1]:5432/",
"http://[::]/",
] {
assert!(validate_outbound_url(url, &config).await.is_err(), "must block {url}");
}
}
#[tokio::test]
async fn refuses_every_blocked_corpus_entry() {
use fraiseql_guard::net::vectors::{MUST_BLOCK, MUST_BLOCK_HOSTS, url_host};
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
for (addr, why) in MUST_BLOCK {
let url = format!("http://{}/latest/meta-data/", url_host(addr));
assert!(
validate_outbound_url(&url, &config).await.is_err(),
"must refuse {addr} ({why})"
);
}
for (host, why) in MUST_BLOCK_HOSTS {
let url = format!("http://{host}/");
assert!(
validate_outbound_url(&url, &config).await.is_err(),
"must refuse {host} ({why})"
);
}
}
#[tokio::test]
async fn permits_every_allowed_corpus_entry() {
use fraiseql_guard::net::vectors::{MUST_ALLOW, url_host};
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
for addr in MUST_ALLOW {
let url = format!("http://{}/", url_host(addr));
assert!(validate_outbound_url(&url, &config).await.is_ok(), "must permit {addr}");
}
}
#[tokio::test]
async fn permits_every_allowed_hostname_at_the_url_entry_point() {
use fraiseql_guard::net::vectors::MUST_ALLOW_HOSTS;
let config = HttpClientConfig {
allowed_domains: vec!["*".to_string()],
..Default::default()
};
for host in MUST_ALLOW_HOSTS {
let url = format!("http://{host}/");
let outcome = validate_outbound_url(&url, &config).await;
let refused_on_a_host_rule = matches!(outcome, Err(FraiseQLError::Authorization { .. }));
assert!(!refused_on_a_host_rule, "must not refuse {host} on a host rule: {outcome:?}");
}
}