Skip to main content

seer_core/
webhook.rs

1//! SSRF-guarded JSON webhook delivery (used by `seer watch --webhook`).
2//!
3//! Watch mode can notify an operator-supplied HTTP endpoint when a watched
4//! domain changes state. That URL is user configuration, which makes this an
5//! outbound-request primitive — so it gets the same SSRF envelope as every
6//! other outbound leg of seer (RDAP, WHOIS, status):
7//!
8//! - Only `http`/`https` URLs with a host are accepted.
9//! - The host resolves through [`crate::net::resolve_public_host`] — the
10//!   single source of truth for reserved-range policy — and every resolved
11//!   address must be public. Loopback, RFC1918, link-local (incl. the cloud
12//!   metadata endpoint), CGNAT, NAT64/6to4 encodings, etc. are refused
13//!   before any connect.
14//! - The validated addresses are pinned on the HTTP client via
15//!   `resolve_to_addrs`, closing the TOCTOU window between validation and
16//!   connect (DNS-rebinding defense). Pinning is a builder-time decision that
17//!   depends on the resolved addresses, which is why the reqwest client is
18//!   built per delivery instead of stored on the struct.
19//! - Redirects are disabled: a 3xx `Location` is a fresh, unvalidated URL, so
20//!   following it would bypass the guard entirely (redirect-based SSRF). A
21//!   redirecting webhook endpoint surfaces as a failed delivery instead.
22//!
23//! Deliveries are single-attempt and timeout-bounded; retry/backoff policy
24//! belongs to the caller (watch mode), matching the `status` module's stance
25//! on not masking flakiness. A non-2xx response surfaces as an error carrying
26//! only the HTTP status — never the response body, which is untrusted remote
27//! content and must not leak into logs or terminal output.
28
29use std::time::Duration;
30
31use reqwest::{Client, Url};
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Result, SeerError};
35
36/// Default per-delivery timeout (connect + write + response headers).
37const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
38
39/// Connect-phase cap, kept no larger than the overall timeout so a sub-5s
40/// configured timeout stays internally consistent (mirrors the RDAP client).
41const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// Outcome of a successful webhook delivery.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WebhookDelivery {
46    /// HTTP status code returned by the endpoint (always 2xx on success).
47    pub status: u16,
48}
49
50/// SSRF-guarded HTTP client that POSTs JSON payloads to a webhook URL.
51///
52/// See the module docs for the security posture (host validation, IP
53/// pinning, redirects disabled).
54#[derive(Debug, Clone)]
55pub struct WebhookClient {
56    /// Per-delivery timeout (default [`DEFAULT_TIMEOUT`]).
57    timeout: Duration,
58    /// When true, skips the reserved-IP SSRF validation so tests can target a
59    /// 127.0.0.1 wiremock fixture. Not settable outside `#[cfg(test)]` builds
60    /// — production deliveries always validate and pin resolved IPs.
61    allow_private: bool,
62}
63
64impl Default for WebhookClient {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl WebhookClient {
71    /// Creates a webhook client with default settings (10s timeout).
72    pub fn new() -> Self {
73        Self {
74            timeout: DEFAULT_TIMEOUT,
75            allow_private: false,
76        }
77    }
78
79    /// Builds a client honoring `~/.seer/config.toml` settings.
80    ///
81    /// Reads `timeouts.http_secs` (already clamped to 1–120s by
82    /// [`crate::config::SeerConfig::load`]) — webhook delivery is an HTTP
83    /// operation, so it shares the HTTP timeout knob. Sugar over
84    /// [`WebhookClient::with_timeout`].
85    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
86        Self::new().with_timeout(config.http_timeout())
87    }
88
89    /// Sets the per-delivery timeout.
90    pub fn with_timeout(mut self, timeout: Duration) -> Self {
91        self.timeout = timeout;
92        self
93    }
94
95    /// Test-only: allow deliveries to loopback/private addresses (mock servers).
96    #[cfg(test)]
97    pub(crate) fn allowing_private_hosts(mut self) -> Self {
98        self.allow_private = true;
99        self
100    }
101
102    /// POSTs `payload` as JSON to `url`.
103    ///
104    /// # Arguments
105    /// * `url` - Webhook endpoint; must be `http`/`https` with a public host
106    /// * `payload` - Any serializable value; sent as the JSON request body
107    ///
108    /// # Returns
109    /// * `Ok(WebhookDelivery)` - the endpoint answered 2xx
110    /// * `Err(SeerError::InvalidInput)` - bad URL shape or SSRF-blocked host
111    /// * `Err(SeerError::JsonError)` - payload failed to serialize
112    /// * `Err(SeerError::HttpError)` - endpoint answered non-2xx (the error
113    ///   names the status; the response body is untrusted and never read)
114    /// * `Err(SeerError::ReqwestError)` - transport failure (connect/timeout)
115    pub async fn post_json<T: Serialize + ?Sized>(
116        &self,
117        url: &str,
118        payload: &T,
119    ) -> Result<WebhookDelivery> {
120        let (host, port) = parse_webhook_url(url)?;
121
122        // Serialize before any network work so a bad payload fails fast
123        // (no DNS lookup, no connect) with a typed JsonError.
124        let body = serde_json::to_vec(payload)?;
125
126        let connect_timeout = CONNECT_TIMEOUT.min(self.timeout);
127        let mut builder = Client::builder()
128            .timeout(self.timeout)
129            .connect_timeout(connect_timeout)
130            .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
131            // SSRF defense: a redirect target is an unvalidated URL — never
132            // follow it (see module docs). Applies to the test seam too, so
133            // tests exercise the same no-redirect behavior as production.
134            .redirect(reqwest::redirect::Policy::none());
135
136        if !self.allow_private {
137            // SSRF protection: refuse reserved/private hosts and pin the
138            // validated addresses so reqwest cannot re-resolve the hostname
139            // to a different (potentially private) address. If the host is
140            // an IP literal the resolved vec already holds it, so
141            // `resolve_to_addrs` is still correct.
142            let resolved = crate::net::resolve_public_host(&host, port).await?;
143            builder = builder.resolve_to_addrs(&host, &resolved);
144        }
145
146        let client = builder
147            .build()
148            .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
149
150        let response = client
151            .post(url)
152            .header(reqwest::header::CONTENT_TYPE, "application/json")
153            .body(body)
154            .send()
155            .await?;
156
157        let status = response.status();
158        if !status.is_success() {
159            // Carry the status only — the response body is untrusted remote
160            // content and is deliberately never read.
161            return Err(SeerError::HttpError(format!(
162                "webhook delivery failed: HTTP {}",
163                status
164            )));
165        }
166
167        Ok(WebhookDelivery {
168            status: status.as_u16(),
169        })
170    }
171}
172
173/// Validates webhook URL shape (scheme + host presence) and extracts
174/// `(host, port)` for the SSRF guard.
175///
176/// Uses `host()` (not `host_str()`) so an IPv6 literal comes back
177/// unbracketed and hits the shared guard's IP-literal short-circuit —
178/// same approach as the RDAP client's URL parsing.
179fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
180    let parsed = Url::parse(url)
181        .map_err(|e| SeerError::InvalidInput(format!("invalid webhook URL '{}': {}", url, e)))?;
182
183    let scheme = parsed.scheme();
184    if scheme != "http" && scheme != "https" {
185        return Err(SeerError::InvalidInput(format!(
186            "webhook URL must use http or https, got '{}'",
187            scheme
188        )));
189    }
190
191    let host = match parsed.host() {
192        Some(url::Host::Domain(d)) => d.to_string(),
193        Some(url::Host::Ipv4(ip)) => ip.to_string(),
194        Some(url::Host::Ipv6(ip)) => ip.to_string(),
195        None => {
196            return Err(SeerError::InvalidInput(format!(
197                "webhook URL '{}' has no host",
198                url
199            )))
200        }
201    };
202
203    // `port_or_known_default` always answers for http/https; the fallback
204    // arm is unreachable but keeps the expression total.
205    let port = parsed
206        .port_or_known_default()
207        .unwrap_or(if scheme == "http" { 80 } else { 443 });
208
209    Ok((host, port))
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used)]
214mod tests {
215    //! Hermetic tests: wiremock serves scripted responses on 127.0.0.1,
216    //! reached through the `#[cfg(test)]`-only `allowing_private_hosts`
217    //! seam. The production-path tests need no network at all (IP-literal
218    //! short-circuit / URL-shape rejection / pre-network serialization).
219
220    use super::*;
221    use wiremock::matchers::{body_json, header, method, path};
222    use wiremock::{Mock, MockServer, ResponseTemplate};
223
224    #[derive(Serialize)]
225    struct Payload {
226        domain: String,
227        event: String,
228    }
229
230    /// A payload whose Serialize impl always fails, to exercise the
231    /// fail-fast serialization path.
232    struct Unserializable;
233
234    impl Serialize for Unserializable {
235        fn serialize<S: serde::Serializer>(
236            &self,
237            _serializer: S,
238        ) -> std::result::Result<S::Ok, S::Error> {
239            Err(serde::ser::Error::custom("cannot serialize"))
240        }
241    }
242
243    #[tokio::test]
244    async fn delivers_json_payload_and_returns_status() {
245        let server = MockServer::start().await;
246        // The mock only matches when the exact JSON body AND the
247        // application/json content type arrive; anything else 404s and the
248        // unwrap below fails — so this asserts the payload round-trips.
249        Mock::given(method("POST"))
250            .and(path("/hook"))
251            .and(header("content-type", "application/json"))
252            .and(body_json(serde_json::json!({
253                "domain": "example.com",
254                "event": "expiry",
255            })))
256            .respond_with(ResponseTemplate::new(200))
257            .expect(1)
258            .mount(&server)
259            .await;
260
261        let client = WebhookClient::new().allowing_private_hosts();
262        let payload = Payload {
263            domain: "example.com".to_string(),
264            event: "expiry".to_string(),
265        };
266        let delivery = client
267            .post_json(&format!("{}/hook", server.uri()), &payload)
268            .await
269            .unwrap();
270        assert_eq!(delivery.status, 200);
271        server.verify().await;
272    }
273
274    #[tokio::test]
275    async fn non_2xx_is_error_naming_status_not_body() {
276        let server = MockServer::start().await;
277        Mock::given(method("POST"))
278            .and(path("/hook"))
279            .respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
280            .mount(&server)
281            .await;
282
283        let client = WebhookClient::new().allowing_private_hosts();
284        let err = client
285            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
286            .await
287            .unwrap_err();
288        assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
289        let msg = err.to_string();
290        assert!(msg.contains("500"), "error must name the status: {msg}");
291        assert!(
292            !msg.contains("internal-secret-detail"),
293            "untrusted response body must not leak into the error: {msg}"
294        );
295    }
296
297    #[tokio::test]
298    async fn redirect_is_not_followed() {
299        let server = MockServer::start().await;
300        Mock::given(method("POST"))
301            .and(path("/hook"))
302            .respond_with(
303                ResponseTemplate::new(301)
304                    .insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
305            )
306            .expect(1)
307            .mount(&server)
308            .await;
309        // The redirect target must never be hit, by any method.
310        Mock::given(path("/elsewhere"))
311            .respond_with(ResponseTemplate::new(200))
312            .expect(0)
313            .mount(&server)
314            .await;
315
316        let client = WebhookClient::new().allowing_private_hosts();
317        let err = client
318            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
319            .await
320            .unwrap_err();
321        assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
322        assert!(
323            err.to_string().contains("301"),
324            "error must carry the 3xx status: {err}"
325        );
326        server.verify().await;
327    }
328
329    #[tokio::test]
330    async fn production_client_refuses_loopback_url() {
331        let server = MockServer::start().await;
332        // No request may ever reach the server through the production path.
333        Mock::given(method("POST"))
334            .respond_with(ResponseTemplate::new(200))
335            .expect(0)
336            .mount(&server)
337            .await;
338
339        let client = WebhookClient::new(); // no seam: production validation
340        let err = client
341            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
342            .await
343            .unwrap_err();
344        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
345        server.verify().await;
346    }
347
348    #[tokio::test]
349    async fn production_client_refuses_private_ip_literal() {
350        // IP-literal short-circuit in the shared guard — no DNS involved.
351        let client = WebhookClient::new();
352        for url in [
353            "http://10.0.0.1/hook",
354            "https://169.254.169.254/latest/meta-data",
355            "http://[::1]/hook",
356        ] {
357            let err = client
358                .post_json(url, &serde_json::json!({}))
359                .await
360                .unwrap_err();
361            assert!(
362                matches!(err, SeerError::InvalidInput(_)),
363                "{url} must be refused, got {err:?}"
364            );
365        }
366    }
367
368    #[tokio::test]
369    async fn rejects_non_http_scheme() {
370        let client = WebhookClient::new();
371        let err = client
372            .post_json("ftp://example.com/hook", &serde_json::json!({}))
373            .await
374            .unwrap_err();
375        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
376        assert!(
377            err.to_string().contains("http"),
378            "error should point at the scheme requirement: {err}"
379        );
380    }
381
382    #[tokio::test]
383    async fn rejects_hostless_url() {
384        let client = WebhookClient::new();
385        let err = client
386            .post_json("http:///hook", &serde_json::json!({}))
387            .await
388            .unwrap_err();
389        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
390    }
391
392    #[tokio::test]
393    async fn serialization_failure_fails_fast_before_any_network() {
394        // Production client + a routable-looking URL: the JsonError must
395        // surface BEFORE DNS resolution/connect (serialization precedes the
396        // SSRF resolve in post_json), keeping this test hermetic.
397        let client = WebhookClient::new();
398        let err = client
399            .post_json("https://webhook.example.com/hook", &Unserializable)
400            .await
401            .unwrap_err();
402        assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
403    }
404
405    #[tokio::test]
406    async fn timeout_bounds_delivery() {
407        let server = MockServer::start().await;
408        Mock::given(method("POST"))
409            .and(path("/hook"))
410            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
411            .mount(&server)
412            .await;
413
414        let client = WebhookClient::new()
415            .allowing_private_hosts()
416            .with_timeout(Duration::from_millis(100));
417        let err = client
418            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
419            .await
420            .unwrap_err();
421        match err {
422            SeerError::ReqwestError { transient, .. } => {
423                assert!(transient, "timeouts must classify as transient");
424            }
425            other => panic!("expected ReqwestError from timeout, got {other:?}"),
426        }
427    }
428}