protocol_droid/adapters/secondary/a2a/webhook_delivery.rs
1//! The HTTP webhook delivery adapter for A2A
2//! push notifications.
3//!
4//! # What this is
5//!
6//! The production implementation of
7//! [`PushNotificationDelivery`].
8//! It resolves the URL's host to an IP, runs
9//! [`check_at_delivery`](crate::application::a2a::UrlFilter::check_at_delivery)
10//! against the resolved IP, and POSTs the body. A redirect to a denied
11//! IP is the same refusal as a direct hit, so the adapter follows
12//! redirects manually rather than letting `reqwest` do it for it.
13//!
14//! # Why a manual redirect loop
15//!
16//! `reqwest`'s redirect policy is global to the client, and the SSRF
17//! check is per-redirect-target. Letting `reqwest` follow a redirect to
18//! `127.0.0.1` is the whole DNS-rebinding / open-redirect SSRF class.
19//! A manual loop means every hop is re-checked against the URL
20//! filter before the request goes out.
21//!
22//! # Why `tokio::net::lookup_host` (and not hickory-resolver)
23//!
24//! The default resolver is the one already on the box. The A2A spec
25//! does not require a particular resolver; the production deployment
26//! that needs `hickory-resolver` writes its own adapter. The default
27//! adapter is the safe one, not the only one.
28//!
29//! # Why the body is `&[u8]`, not `serde_json::Value`
30//!
31//! The delivery port is byte-level: the body is whatever the producer
32//! serialised. The adapter does not parse it; parsing would mean the
33//! adapter owns the wire shape, and a future version that changes the
34//! shape would break the adapter, not the producer.
35
36use std::sync::Arc;
37use std::time::Duration;
38
39use reqwest::redirect::Policy;
40use url::Url;
41
42use crate::application::a2a::{
43 FilterDenial, NotificationConfig, NotificationConfigError, PushNotificationDelivery, UrlFilter,
44};
45use crate::domain::a2a::A2aVersion;
46
47/// The HTTP webhook delivery adapter.
48///
49/// # Why a `reqwest::Client` and not a `tokio::net::TcpStream`
50///
51/// A consumer who needs a custom HTTP client (a mock for tests, a
52/// custom TLS configuration, a different redirect policy) passes
53/// their own. The default is a 10-second connect / 10-second read
54/// timeout — long enough for a slow webhook target, short enough
55/// that a hung target does not tie up a worker.
56pub struct WebhookDelivery {
57 /// The HTTP client used to POST the body.
58 client: reqwest::Client,
59 /// The URL filter that re-checks every hop.
60 filter: Arc<dyn UrlFilter>,
61 /// The maximum number of redirects before the adapter gives up.
62 max_redirects: u8,
63}
64
65impl std::fmt::Debug for WebhookDelivery {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.debug_struct("WebhookDelivery")
68 .field("max_redirects", &self.max_redirects)
69 .finish_non_exhaustive()
70 }
71}
72
73impl WebhookDelivery {
74 /// Builds a new webhook delivery adapter
75 /// with the default timeouts (10
76 /// seconds connect, 10 seconds
77 /// read) and a redirect limit of
78 /// 5. The redirect limit is a
79 /// guard against infinite
80 /// redirect loops, not a
81 /// quality-of-service promise.
82 #[must_use]
83 pub fn new(filter: Arc<dyn UrlFilter>) -> Self {
84 // A no-redirect policy at the
85 // client level forces the
86 // manual loop in `deliver`
87 // to do the work — the SSRF
88 // check on every hop would
89 // be bypassed if the client
90 // followed redirects on its
91 // own.
92 let client = reqwest::Client::builder()
93 .timeout(Duration::from_secs(10))
94 .connect_timeout(Duration::from_secs(10))
95 .redirect(Policy::none())
96 .build();
97 let Ok(client) = client else {
98 // A misconfigured TLS stack
99 // is a startup-time error;
100 // a sane default does not
101 // exist for a `reqwest::Client`
102 // that could not be built.
103 // The unwrap_or_default
104 // path uses reqwest's
105 // default builder.
106 return Self::with_client(reqwest::Client::new(), filter);
107 };
108 Self {
109 client,
110 filter,
111 max_redirects: 5,
112 }
113 }
114
115 /// Builds a new webhook delivery
116 /// adapter with a caller-supplied
117 /// `reqwest::Client`. The adapter
118 /// does not modify the client; the
119 /// caller owns TLS, redirect, and
120 /// timeout configuration.
121 #[must_use]
122 pub fn with_client(client: reqwest::Client, filter: Arc<dyn UrlFilter>) -> Self {
123 Self {
124 client,
125 filter,
126 max_redirects: 5,
127 }
128 }
129}
130
131impl PushNotificationDelivery for WebhookDelivery {
132 async fn deliver(
133 &self,
134 config: &NotificationConfig,
135 body: &[u8],
136 ) -> Result<(), NotificationConfigError> {
137 let mut current_url: Url = config.webhook.as_url().clone();
138 // The redirect loop. Each
139 // hop is re-checked against
140 // the URL filter; a redirect
141 // to a denied IP is the
142 // same refusal as a direct
143 // hit.
144 for hop in 0..=self.max_redirects {
145 if hop == self.max_redirects {
146 return Err(NotificationConfigError::TransientFailure {
147 reason: format!("exceeded {} redirects", self.max_redirects),
148 });
149 }
150
151 // DNS resolution.
152 // `lookup_host` returns
153 // the first resolved IP
154 // for the host. A
155 // deployment that needs
156 // to check every IP
157 // walks them all; the
158 // default refuses on
159 // the first denied IP.
160 let host = current_url
161 .host_str()
162 .ok_or(NotificationConfigError::Refused(FilterDenial::MissingHost))?
163 .to_owned();
164 let port = current_url
165 .port_or_known_default()
166 .ok_or(NotificationConfigError::Refused(FilterDenial::Malformed))?;
167 let mut resolved_ips = Vec::new();
168 let lookup = tokio::net::lookup_host((host.as_str(), port)).await;
169 match lookup {
170 Ok(addrs) => {
171 for addr in addrs {
172 resolved_ips.push(addr.ip());
173 }
174 }
175 Err(error) => {
176 return Err(NotificationConfigError::TransientFailure {
177 reason: format!("DNS resolution failed: {error}"),
178 });
179 }
180 }
181 if resolved_ips.is_empty() {
182 return Err(NotificationConfigError::TransientFailure {
183 reason: format!("no addresses resolved for {host}"),
184 });
185 }
186
187 // SSRF check on the
188 // resolved IP. A denied
189 // IP is a refusal, not a
190 // transient failure —
191 // retrying will not make
192 // it through.
193 for ip in &resolved_ips {
194 if let Err(_denial) =
195 self.filter
196 .check_at_delivery(¤t_url, *ip, A2aVersion::V1_0)
197 {
198 return Err(NotificationConfigError::Refused(FilterDenial::MissingHost));
199 }
200 }
201
202 // POST.
203 let mut request = self
204 .client
205 .post(current_url.as_str())
206 .header("content-type", "application/json")
207 .body(body.to_vec());
208 if let Some(token) = &config.token {
209 request = request.header("x-a2a-notification-token", token.as_ref());
210 }
211 let response = request.send().await.map_err(|error| {
212 NotificationConfigError::TransientFailure {
213 reason: format!("HTTP POST failed: {error}"),
214 }
215 })?;
216
217 // Status code.
218 let status = response.status();
219 if status.is_success() {
220 return Ok(());
221 }
222 if status.is_redirection() {
223 if let Some(location) = response.headers().get(reqwest::header::LOCATION) {
224 let location_str = location.to_str().map_err(|error| {
225 NotificationConfigError::TransientFailure {
226 reason: format!("redirect location is not valid UTF-8: {error}"),
227 }
228 })?;
229 let next_url = current_url.join(location_str).map_err(|error| {
230 NotificationConfigError::TransientFailure {
231 reason: format!("redirect location is not a valid URL: {error}"),
232 }
233 })?;
234 current_url = next_url;
235 continue;
236 }
237 return Err(NotificationConfigError::TransientFailure {
238 reason: "redirect without a location".to_owned(),
239 });
240 }
241 return Err(NotificationConfigError::TransientFailure {
242 reason: format!("HTTP {status}"),
243 });
244 }
245 Err(NotificationConfigError::TransientFailure {
246 reason: "redirect loop ended without a response".to_owned(),
247 })
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use std::sync::Arc;
254
255 use super::WebhookDelivery;
256 use crate::adapters::secondary::a2a::SsrfDenylist;
257
258 #[tokio::test]
259 async fn webhook_delivery_can_be_constructed_with_default_filter() {
260 // A smoke test: the adapter
261 // builds with the default
262 // filter and implements the
263 // trait. The actual delivery
264 // path needs a real HTTP
265 // target, which a unit test
266 // cannot provide.
267 let delivery = WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER));
268 let _ = format!("{delivery:?}");
269 }
270
271 #[tokio::test]
272 async fn webhook_delivery_with_client_can_be_constructed() {
273 // A smoke test for the
274 // caller-supplied client
275 // path.
276 let client = reqwest::Client::new();
277 let delivery = WebhookDelivery::with_client(client, Arc::new(SsrfDenylist::FILTER));
278 let _ = format!("{delivery:?}");
279 }
280}