Skip to main content

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(&current_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}