use a2a::*;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
pub struct HttpPushSender {
client: reqwest::Client,
fail_on_error: bool,
validate_urls: bool,
}
pub struct HttpPushSenderConfig {
pub timeout: Duration,
pub fail_on_error: bool,
pub validate_urls: bool,
}
impl Default for HttpPushSenderConfig {
fn default() -> Self {
HttpPushSenderConfig {
timeout: Duration::from_secs(30),
fail_on_error: false,
validate_urls: true,
}
}
}
impl HttpPushSender {
pub fn new(config: Option<HttpPushSenderConfig>) -> Self {
let config = config.unwrap_or_default();
let mut builder = reqwest::Client::builder().timeout(config.timeout);
if config.validate_urls {
builder = builder
.dns_resolver(Arc::new(SsrfGuardedResolver))
.no_proxy()
.redirect(ssrf_guarded_redirect_policy());
}
let client = builder.build().expect("failed to create HTTP client");
HttpPushSender {
client,
fail_on_error: config.fail_on_error,
validate_urls: config.validate_urls,
}
}
pub async fn send_push(
&self,
config: &TaskPushNotificationConfig,
event: StreamResponse,
) -> Result<(), A2AError> {
if self.validate_urls {
validate_push_url(&config.url)?;
}
if let Some(ref token) = config.token {
if token.contains('\r') || token.contains('\n') {
return Err(A2AError::invalid_params(
"push notification token must not contain CR/LF",
));
}
}
if let Some(ref auth) = config.authentication {
if let Some(ref creds) = auth.credentials {
if creds.contains('\r') || creds.contains('\n') {
return Err(A2AError::invalid_params(
"push credentials must not contain CR/LF",
));
}
}
}
let body = match serde_json::to_vec(&event) {
Ok(b) => b,
Err(e) => return self.handle_error(format!("failed to serialize event: {e}")),
};
let mut request = self
.client
.post(&config.url)
.header("Content-Type", "application/json")
.body(body);
if let Some(ref token) = config.token {
request = request.header("A2A-Notification-Token", token);
}
if let Some(ref auth) = config.authentication {
if let Some(ref creds) = auth.credentials {
match auth.scheme.to_lowercase().as_str() {
"bearer" => {
request = request.header("Authorization", format!("Bearer {creds}"));
}
"basic" => {
request = request.header("Authorization", format!("Basic {creds}"));
}
_ => {}
}
}
}
match request.send().await {
Ok(resp) => {
if !resp.status().is_success() {
return self
.handle_error(format!("push endpoint returned status: {}", resp.status()));
}
Ok(())
}
Err(e) => self.handle_error(format!("failed to send push notification: {e}")),
}
}
fn handle_error(&self, msg: String) -> Result<(), A2AError> {
if self.fail_on_error {
Err(crate::sanitized_internal_error(&msg))
} else {
tracing::error!("{}", msg);
Ok(())
}
}
}
pub(crate) fn validate_push_url(url: &str) -> Result<(), A2AError> {
let parsed = reqwest::Url::parse(url)
.map_err(|_| A2AError::invalid_params("invalid push notification URL"))?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(A2AError::invalid_params("push URL must be http or https"));
}
if let Some(host) = parsed.host_str() {
let host = host.strip_suffix('.').unwrap_or(host);
let blocked = [
"127.0.0.1",
"localhost",
"::1",
"[::1]",
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.com",
"metadata.goog",
"0.0.0.0",
];
if blocked.contains(&host) {
return Err(A2AError::invalid_params("push URL targets blocked host"));
}
let host_unbracketed = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
if let Ok(ip) = host_unbracketed.parse::<std::net::IpAddr>() {
if is_blocked_ip(ip) {
return Err(A2AError::invalid_params(
"push URL targets private/loopback/link-local address",
));
}
}
}
Ok(())
}
pub(crate) fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
}
std::net::IpAddr::V6(v6) => {
let is_ula = (v6.segments()[0] & 0xfe00) == 0xfc00;
let mapped_v4_blocked = v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
});
v6.is_loopback()
|| is_ula
|| mapped_v4_blocked
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
|| v6.is_multicast()
}
}
}
fn connectable_addrs(addrs: impl Iterator<Item = SocketAddr>) -> Vec<SocketAddr> {
addrs.filter(|addr| !is_blocked_ip(addr.ip())).collect()
}
struct SsrfGuardedResolver;
impl Resolve for SsrfGuardedResolver {
fn resolve(&self, name: Name) -> Resolving {
Box::pin(async move {
let host = name.as_str().to_owned();
let resolved = tokio::net::lookup_host((host.as_str(), 0)).await?;
let allowed = connectable_addrs(resolved);
if allowed.is_empty() {
return Err(Box::<dyn std::error::Error + Send + Sync>::from(
"push URL host resolves only to blocked (loopback, private, \
link-local, or metadata) addresses",
));
}
Ok(Box::new(allowed.into_iter()) as Addrs)
})
}
}
fn ssrf_guarded_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
return attempt.stop();
}
if validate_push_url(attempt.url().as_str()).is_err() {
attempt.error("push URL redirect target is blocked")
} else {
attempt.follow()
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::install_crypto_provider;
#[test]
fn test_default_config() {
let config = HttpPushSenderConfig::default();
assert_eq!(config.timeout, Duration::from_secs(30));
assert!(!config.fail_on_error);
assert!(config.validate_urls);
}
#[test]
fn test_sender_new_default() {
install_crypto_provider();
let sender = HttpPushSender::new(None);
assert!(!sender.fail_on_error);
assert!(sender.validate_urls);
}
#[test]
fn test_sender_new_custom() {
install_crypto_provider();
let config = HttpPushSenderConfig {
timeout: Duration::from_secs(10),
fail_on_error: true,
validate_urls: false,
};
let sender = HttpPushSender::new(Some(config));
assert!(sender.fail_on_error);
assert!(!sender.validate_urls);
}
#[test]
fn test_handle_error_fail_on_error() {
install_crypto_provider();
let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
timeout: Duration::from_secs(5),
fail_on_error: true,
validate_urls: true,
}));
let result = sender.handle_error("test error".to_string());
assert!(result.is_err());
}
#[test]
fn test_handle_error_ignore() {
install_crypto_provider();
let sender = HttpPushSender::new(None);
let result = sender.handle_error("test error".to_string());
assert!(result.is_ok());
}
fn sample_status_update() -> StreamResponse {
use a2a::event::*;
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: "t1".into(),
context_id: "c1".into(),
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
metadata: None,
})
}
fn sample_config(url: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
task_id: String::new(),
url: url.to_string(),
id: None,
token: None,
authentication: None,
tenant: None,
}
}
#[tokio::test]
async fn test_send_push_connection_refused_no_fail() {
install_crypto_provider();
let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
validate_urls: false,
..Default::default()
}));
let config = TaskPushNotificationConfig {
task_id: String::new(),
url: "http://127.0.0.1:1/callback".to_string(),
id: None,
token: Some("tok".to_string()),
authentication: Some(AuthenticationInfo {
scheme: "bearer".to_string(),
credentials: Some("secret".to_string()),
}),
tenant: None,
};
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_push_connection_refused_fail() {
install_crypto_provider();
let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
timeout: std::time::Duration::from_millis(100),
fail_on_error: true,
validate_urls: false,
}));
let config = sample_config("http://127.0.0.1:1/callback");
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_send_push_with_basic_auth() {
install_crypto_provider();
let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
validate_urls: false,
..Default::default()
}));
let config = TaskPushNotificationConfig {
task_id: String::new(),
url: "http://127.0.0.1:1/callback".to_string(),
id: None,
token: None,
authentication: Some(AuthenticationInfo {
scheme: "basic".to_string(),
credentials: Some("dXNlcjpwYXNz".to_string()),
}),
tenant: None,
};
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_ok()); }
#[tokio::test]
async fn test_send_push_with_unknown_auth_scheme() {
install_crypto_provider();
let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
validate_urls: false,
..Default::default()
}));
let config = TaskPushNotificationConfig {
task_id: String::new(),
url: "http://127.0.0.1:1/callback".to_string(),
id: None,
token: None,
authentication: Some(AuthenticationInfo {
scheme: "custom".to_string(),
credentials: Some("cred".to_string()),
}),
tenant: None,
};
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_ok());
}
#[test]
fn test_validate_push_url_rejects_non_http_schemes() {
for url in [
"file:///etc/passwd",
"ftp://example.com/hook",
"gopher://example.com/",
] {
let err = validate_push_url(url).unwrap_err();
assert_eq!(err.code, error_code::INVALID_PARAMS);
}
}
#[test]
fn test_validate_push_url_rejects_loopback() {
for url in [
"http://127.0.0.1:1/callback",
"http://localhost/callback",
"http://[::1]/callback",
"http://0.0.0.0/callback",
] {
assert!(
validate_push_url(url).is_err(),
"expected {url} to be rejected"
);
}
}
#[test]
fn test_validate_push_url_rejects_private_and_link_local() {
for url in [
"http://10.0.0.1/callback",
"http://192.168.1.1/callback",
"http://172.16.0.1/callback",
"http://169.254.169.254/latest/meta-data/",
"http://169.254.1.1/callback",
"http://metadata.google.internal/callback",
] {
assert!(
validate_push_url(url).is_err(),
"expected {url} to be rejected"
);
}
}
#[test]
fn test_validate_push_url_rejects_trailing_dot_hosts() {
for url in [
"http://localhost./hook",
"http://LocalHost./hook",
"https://localhost./hook",
"http://metadata.google.internal./hook",
"http://metadata.azure.com./hook",
] {
assert!(
validate_push_url(url).is_err(),
"{url} must be blocked, but was allowed"
);
}
}
#[test]
fn test_validate_push_url_rejects_alternate_ipv4_spellings() {
for url in [
"http://2130706433/hook", "http://0x7f.0.0.1/hook", "http://127.1/hook", "http://127.0.0.1./hook", "http://[::ffff:127.0.0.1]/hook", ] {
assert!(
validate_push_url(url).is_err(),
"{url} must be blocked, but was allowed"
);
}
}
#[test]
fn test_is_blocked_ip_checks_every_range_through_an_ipv4_mapped_address() {
for (ip, why) in [
("::ffff:169.254.1.1", "link-local"),
("::ffff:0.0.0.0", "unspecified"),
("::ffff:224.0.0.1", "multicast"),
] {
let addr: std::net::IpAddr = ip.parse().unwrap();
assert!(is_blocked_ip(addr), "{ip} ({why}) should be blocked");
}
let public: std::net::IpAddr = "::ffff:93.184.216.34".parse().unwrap();
assert!(
!is_blocked_ip(public),
"a public mapped address was blocked"
);
}
#[test]
fn test_validate_push_url_rejects_blocked_hosts_regardless_of_case() {
for url in [
"http://LOCALHOST/hook",
"http://METADATA.GOOGLE.INTERNAL/hook",
"http://Metadata.Azure.Com/hook",
] {
assert!(
validate_push_url(url).is_err(),
"{url} must be blocked, but was allowed"
);
}
}
#[test]
fn test_validate_push_url_accepts_public_urls() {
for url in [
"http://example.com/callback",
"https://example.com/callback",
"https://hooks.example.com/abc?x=1",
] {
assert!(
validate_push_url(url).is_ok(),
"expected {url} to be accepted"
);
}
}
#[tokio::test]
async fn test_send_push_rejects_blocked_url_even_without_fail_on_error() {
install_crypto_provider();
let sender = HttpPushSender::new(None); let config = sample_config("http://127.0.0.1:1/callback");
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
#[tokio::test]
async fn test_send_push_rejects_crlf_credentials() {
install_crypto_provider();
let sender = HttpPushSender::new(None);
let config = TaskPushNotificationConfig {
task_id: String::new(),
url: "https://example.com/callback".to_string(),
id: None,
token: None,
authentication: Some(AuthenticationInfo {
scheme: "bearer".to_string(),
credentials: Some("secret\r\nX-Injected: 1".to_string()),
}),
tenant: None,
};
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
#[tokio::test]
async fn test_send_push_rejects_crlf_token() {
install_crypto_provider();
let sender = HttpPushSender::new(None);
let config = TaskPushNotificationConfig {
task_id: String::new(),
url: "https://example.com/callback".to_string(),
id: None,
token: Some("tok\nX-Injected: 1".to_string()),
authentication: None,
tenant: None,
};
let result = sender.send_push(&config, sample_status_update()).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
#[test]
fn test_validate_push_url_rejects_invalid_url() {
assert!(validate_push_url("not a url").is_err());
assert!(validate_push_url("").is_err());
assert!(validate_push_url("http://").is_err());
}
#[test]
fn test_validate_push_url_rejects_ipv6_restricted_ranges() {
for url in [
"http://[fc00::1]/cb",
"http://[fd12:3456:789a::1]/cb",
"http://[fe80::1]/cb",
"http://[ff02::1]/cb",
"http://[::]/cb",
] {
let result = validate_push_url(url);
assert!(result.is_err(), "expected {url} to be rejected");
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
}
#[test]
fn test_validate_push_url_rejects_ipv4_mapped_ipv6() {
for url in [
"http://[::ffff:127.0.0.1]/cb",
"http://[::ffff:10.0.0.1]/cb",
] {
let result = validate_push_url(url);
assert!(result.is_err(), "expected {url} to be rejected");
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
}
#[test]
fn test_validate_push_url_rejects_ipv4_multicast() {
let result = validate_push_url("http://224.0.0.1/cb");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
}
#[test]
fn test_validate_push_url_accepts_global_ipv6() {
assert!(validate_push_url("http://[2606:4700::1111]/cb").is_ok());
}
#[test]
fn test_connectable_addrs_drops_blocked_keeps_public() {
let addrs: Vec<SocketAddr> = vec![
"127.0.0.1:443".parse().unwrap(), "10.0.0.5:443".parse().unwrap(), "169.254.169.254:80".parse().unwrap(), "93.184.216.34:443".parse().unwrap(), "[fc00::1]:443".parse().unwrap(), "[2606:4700::1111]:443".parse().unwrap(), ];
let ips: Vec<std::net::IpAddr> = connectable_addrs(addrs.into_iter())
.iter()
.map(|a| a.ip())
.collect();
assert_eq!(
ips,
vec![
"93.184.216.34".parse::<std::net::IpAddr>().unwrap(),
"2606:4700::1111".parse::<std::net::IpAddr>().unwrap(),
]
);
}
#[test]
fn test_connectable_addrs_all_blocked_is_empty() {
let addrs: Vec<SocketAddr> = vec![
"127.0.0.1:443".parse().unwrap(),
"192.168.1.1:443".parse().unwrap(),
];
assert!(connectable_addrs(addrs.into_iter()).is_empty());
}
#[tokio::test]
async fn test_ssrf_resolver_rejects_host_resolving_to_loopback() {
let name: Name = "localhost".parse().expect("localhost is a valid dns name");
let result = SsrfGuardedResolver.resolve(name).await;
assert!(
result.is_err(),
"a host resolving only to loopback must be refused at connect time"
);
}
#[test]
fn test_redirect_hop_check_rejects_blocked_targets() {
assert!(validate_push_url("http://127.0.0.1/").is_err());
assert!(validate_push_url("http://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_push_url("http://[::1]/").is_err());
assert!(validate_push_url("https://example.com/next").is_ok());
}
async fn spawn_redirect_server(location: String) -> String {
let app = axum::Router::new().route(
"/",
axum::routing::get(move || {
let location = location.clone();
async move { axum::response::Redirect::temporary(&location) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}/")
}
#[tokio::test]
async fn test_redirect_policy_blocks_a_redirect_to_a_disallowed_target() {
let server_url =
spawn_redirect_server("http://169.254.169.254/latest/meta-data/".into()).await;
let client = reqwest::Client::builder()
.redirect(ssrf_guarded_redirect_policy())
.build()
.unwrap();
let err = client
.get(&server_url)
.send()
.await
.expect_err("a redirect to a blocked target must fail");
assert!(err.is_redirect(), "expected a redirect-policy error: {err}");
}
#[tokio::test]
async fn test_redirect_policy_rejects_a_self_redirect_loop_immediately() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let self_url = format!("http://{addr}/");
let app = axum::Router::new().route(
"/",
axum::routing::get(move || {
let self_url = self_url.clone();
async move { axum::response::Redirect::temporary(&self_url) }
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::builder()
.redirect(ssrf_guarded_redirect_policy())
.build()
.unwrap();
let err = client
.get(format!("http://{addr}/"))
.send()
.await
.expect_err("a loopback self-redirect must be refused, not followed");
assert!(err.is_redirect(), "expected a redirect-policy error: {err}");
}
}