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