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::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::DnsError)` - the endpoint's host could not be resolved
112    /// * `Err(SeerError::JsonError)` - payload failed to serialize
113    /// * `Err(SeerError::HttpError)` - endpoint answered non-2xx (the error
114    ///   names the status; the response body is untrusted and never read)
115    /// * `Err(SeerError::ReqwestError)` - transport failure (connect/timeout)
116    ///
117    /// No error carries the URL: webhook URLs routinely embed a secret token
118    /// in the path or query.
119    pub async fn post_json<T: Serialize + ?Sized>(
120        &self,
121        url: &str,
122        payload: &T,
123    ) -> Result<WebhookDelivery> {
124        let (host, port) = parse_webhook_url(url)?;
125
126        // Serialize before any network work so a bad payload fails fast
127        // (no DNS lookup, no connect) with a typed JsonError.
128        let body = serde_json::to_vec(payload)?;
129
130        // Never follows redirects (see module docs) — the test seam included,
131        // so tests exercise the same no-redirect behavior as production.
132        let mut builder = crate::net::client_builder(self.timeout)
133            .connect_timeout(CONNECT_TIMEOUT.min(self.timeout))
134            .user_agent(crate::net::USER_AGENT);
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        // reqwest's error text includes the request URL; drop it.
151        let response = client
152            .post(url)
153            .header(reqwest::header::CONTENT_TYPE, "application/json")
154            .body(body)
155            .send()
156            .await
157            .map_err(|e| SeerError::from(e.without_url()))?;
158
159        let status = response.status();
160        if !status.is_success() {
161            // Carry the status only — the response body is untrusted remote
162            // content and is deliberately never read.
163            return Err(SeerError::HttpError(format!(
164                "webhook delivery failed: HTTP {}",
165                status
166            )));
167        }
168
169        Ok(WebhookDelivery {
170            status: status.as_u16(),
171        })
172    }
173}
174
175/// Validates webhook URL shape (scheme + host presence) and extracts
176/// `(host, port)` for the SSRF guard. Any port is allowed: the endpoint is
177/// operator configuration, not a server-supplied redirect.
178fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
179    // The URL itself is never echoed: it may carry a secret token.
180    let parsed = Url::parse(url)
181        .map_err(|e| SeerError::InvalidInput(format!("invalid webhook 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 = crate::net::url_host(&parsed)
192        .ok_or_else(|| SeerError::InvalidInput("webhook URL has no host".to_string()))?;
193
194    // `port_or_known_default` always answers for http/https; the fallback
195    // arm is unreachable but keeps the expression total.
196    let port = parsed
197        .port_or_known_default()
198        .unwrap_or(if scheme == "http" { 80 } else { 443 });
199
200    Ok((host, port))
201}
202
203#[cfg(test)]
204#[allow(clippy::unwrap_used)]
205mod tests {
206    //! Hermetic tests: wiremock serves scripted responses on 127.0.0.1,
207    //! reached through the `#[cfg(test)]`-only `allowing_private_hosts`
208    //! seam. The production-path tests need no network at all (IP-literal
209    //! short-circuit / URL-shape rejection / pre-network serialization).
210
211    use super::*;
212    use wiremock::matchers::{body_json, header, method, path};
213    use wiremock::{Mock, MockServer, ResponseTemplate};
214
215    #[derive(Serialize)]
216    struct Payload {
217        domain: String,
218        event: String,
219    }
220
221    /// A payload whose Serialize impl always fails, to exercise the
222    /// fail-fast serialization path.
223    struct Unserializable;
224
225    impl Serialize for Unserializable {
226        fn serialize<S: serde::Serializer>(
227            &self,
228            _serializer: S,
229        ) -> std::result::Result<S::Ok, S::Error> {
230            Err(serde::ser::Error::custom("cannot serialize"))
231        }
232    }
233
234    #[tokio::test]
235    async fn delivers_json_payload_and_returns_status() {
236        let server = MockServer::start().await;
237        // The mock only matches when the exact JSON body AND the
238        // application/json content type arrive; anything else 404s and the
239        // unwrap below fails — so this asserts the payload round-trips.
240        Mock::given(method("POST"))
241            .and(path("/hook"))
242            .and(header("content-type", "application/json"))
243            .and(body_json(serde_json::json!({
244                "domain": "example.com",
245                "event": "expiry",
246            })))
247            .respond_with(ResponseTemplate::new(200))
248            .expect(1)
249            .mount(&server)
250            .await;
251
252        let client = WebhookClient::new().allowing_private_hosts();
253        let payload = Payload {
254            domain: "example.com".to_string(),
255            event: "expiry".to_string(),
256        };
257        let delivery = client
258            .post_json(&format!("{}/hook", server.uri()), &payload)
259            .await
260            .unwrap();
261        assert_eq!(delivery.status, 200);
262        server.verify().await;
263    }
264
265    #[tokio::test]
266    async fn non_2xx_is_error_naming_status_not_body() {
267        let server = MockServer::start().await;
268        Mock::given(method("POST"))
269            .and(path("/hook"))
270            .respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
271            .mount(&server)
272            .await;
273
274        let client = WebhookClient::new().allowing_private_hosts();
275        let err = client
276            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
277            .await
278            .unwrap_err();
279        assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
280        let msg = err.to_string();
281        assert!(msg.contains("500"), "error must name the status: {msg}");
282        assert!(
283            !msg.contains("internal-secret-detail"),
284            "untrusted response body must not leak into the error: {msg}"
285        );
286    }
287
288    #[tokio::test]
289    async fn redirect_is_not_followed() {
290        let server = MockServer::start().await;
291        Mock::given(method("POST"))
292            .and(path("/hook"))
293            .respond_with(
294                ResponseTemplate::new(301)
295                    .insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
296            )
297            .expect(1)
298            .mount(&server)
299            .await;
300        // The redirect target must never be hit, by any method.
301        Mock::given(path("/elsewhere"))
302            .respond_with(ResponseTemplate::new(200))
303            .expect(0)
304            .mount(&server)
305            .await;
306
307        let client = WebhookClient::new().allowing_private_hosts();
308        let err = client
309            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
310            .await
311            .unwrap_err();
312        assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
313        assert!(
314            err.to_string().contains("301"),
315            "error must carry the 3xx status: {err}"
316        );
317        server.verify().await;
318    }
319
320    #[tokio::test]
321    async fn production_client_refuses_loopback_url() {
322        let server = MockServer::start().await;
323        // No request may ever reach the server through the production path.
324        Mock::given(method("POST"))
325            .respond_with(ResponseTemplate::new(200))
326            .expect(0)
327            .mount(&server)
328            .await;
329
330        let client = WebhookClient::new(); // no seam: production validation
331        let err = client
332            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
333            .await
334            .unwrap_err();
335        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
336        server.verify().await;
337    }
338
339    #[tokio::test]
340    async fn production_client_refuses_private_ip_literal() {
341        // IP-literal short-circuit in the shared guard — no DNS involved.
342        let client = WebhookClient::new();
343        for url in [
344            "http://10.0.0.1/hook",
345            "https://169.254.169.254/latest/meta-data",
346            "http://[::1]/hook",
347        ] {
348            let err = client
349                .post_json(url, &serde_json::json!({}))
350                .await
351                .unwrap_err();
352            assert!(
353                matches!(err, SeerError::InvalidInput(_)),
354                "{url} must be refused, got {err:?}"
355            );
356        }
357    }
358
359    #[tokio::test]
360    async fn rejects_non_http_scheme() {
361        let client = WebhookClient::new();
362        let err = client
363            .post_json("ftp://example.com/hook", &serde_json::json!({}))
364            .await
365            .unwrap_err();
366        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
367        assert!(
368            err.to_string().contains("http"),
369            "error should point at the scheme requirement: {err}"
370        );
371    }
372
373    #[tokio::test]
374    async fn rejects_hostless_url() {
375        // Note `http:///hook` is NOT hostless: WHATWG URL parsing reads it as
376        // host `hook`, so it only failed here via a live DNS miss. `http://`
377        // really has no host and must be refused before any network.
378        let client = WebhookClient::new();
379        let err = client
380            .post_json("http://", &serde_json::json!({}))
381            .await
382            .unwrap_err();
383        assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
384    }
385
386    #[tokio::test]
387    async fn serialization_failure_fails_fast_before_any_network() {
388        // Production client + a routable-looking URL: the JsonError must
389        // surface BEFORE DNS resolution/connect (serialization precedes the
390        // SSRF resolve in post_json), keeping this test hermetic.
391        let client = WebhookClient::new();
392        let err = client
393            .post_json("https://webhook.example.com/hook", &Unserializable)
394            .await
395            .unwrap_err();
396        assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
397    }
398
399    #[tokio::test]
400    async fn timeout_bounds_delivery() {
401        let server = MockServer::start().await;
402        Mock::given(method("POST"))
403            .and(path("/hook"))
404            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
405            .mount(&server)
406            .await;
407
408        let client = WebhookClient::new()
409            .allowing_private_hosts()
410            .with_timeout(Duration::from_millis(100));
411        let err = client
412            .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
413            .await
414            .unwrap_err();
415        match err {
416            SeerError::ReqwestError { transient, .. } => {
417                assert!(transient, "timeouts must classify as transient");
418            }
419            other => panic!("expected ReqwestError from timeout, got {other:?}"),
420        }
421    }
422
423    /// Regression: reqwest's transport-error text carries the request URL,
424    /// and webhook URLs embed secret tokens.
425    #[tokio::test]
426    async fn errors_never_carry_the_url() {
427        // Bind-and-drop: a loopback port with no listener refuses at once.
428        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
429        let port = listener.local_addr().unwrap().port();
430        drop(listener);
431
432        let client = WebhookClient::new().allowing_private_hosts();
433        let err = client
434            .post_json(
435                &format!("http://127.0.0.1:{port}/hooks/s3cr3t-token?key=hunter2"),
436                &serde_json::json!({}),
437            )
438            .await
439            .unwrap_err();
440        assert!(matches!(err, SeerError::ReqwestError { .. }), "got {err:?}");
441        let msg = err.to_string();
442        assert!(!msg.contains("s3cr3t-token"), "{msg}");
443        assert!(!msg.contains("hunter2"), "{msg}");
444
445        let err = client
446            .post_json("ftp://example.com/s3cr3t-token", &serde_json::json!({}))
447            .await
448            .unwrap_err();
449        assert!(!err.to_string().contains("s3cr3t-token"), "{err}");
450        let err = client
451            .post_json("http//s3cr3t-token", &serde_json::json!({}))
452            .await
453            .unwrap_err();
454        assert!(!err.to_string().contains("s3cr3t-token"), "{err}");
455    }
456}