Skip to main content

cratefield_adapter_webhook_tracker/
lib.rs

1//! `cratefield-adapter-webhook-tracker`: the [`Tracker`] port over a
2//! tenant-configured webhook (issue #432). It POSTs the ticket as a JSON
3//! document signed with HMAC-SHA256, through the runtime's [`HttpClient`]
4//! and [`Clock`] ports — no vendor SDK — so the same adapter runs on Workers
5//! and natively (ADR 0002: the port lives in core, the vendor client here).
6//!
7//! **The URL is attacker-adjacent, so it is never fetched directly.** A
8//! webhook destination comes from tenant config, which is exactly the kind
9//! of value an SSRF rides in on. It goes out through the [`HttpClient`]
10//! port, whose contract refuses non-`http(s)` schemes, userinfo, and
11//! loopback, private, link-local and cloud-metadata destinations —
12//! re-vetting every redirect hop — and reports a refusal as
13//! [`HttpError::BlockedDestination`], which this adapter maps to
14//! [`TrackerError::Rejected`]: a blocked destination is a config error, not
15//! weather a retry fixes.
16//!
17//! **Idempotency is delegated.** A webhook cannot be searched the way the
18//! sibling `cratefield-adapter-github-issues` searches a repository, so the
19//! *receiver* dedupes on the draft's `idempotency_key`, which the payload
20//! carries. There is no tracker-side id to learn back, so the [`Filed`]
21//! this adapter returns reports the key as the id and `deduplicated: false`.
22
23#![doc = include_str!("../README.md")]
24#![forbid(unsafe_code)]
25
26use async_trait::async_trait;
27use bytes::Bytes;
28use cratefield_core::{
29    Clock, Credential, Destination, Filed, HttpClient, HttpError, TicketDraft, TicketStatus,
30    Tracker, TrackerError, retry_after,
31};
32use hmac::{Hmac, KeyInit, Mac};
33use http::header::CONTENT_TYPE;
34use http::{Request, StatusCode};
35use sha2::Sha256;
36use std::fmt::Write as _;
37use std::sync::Arc;
38
39/// The one header a delivery is verified by, Stripe-style:
40/// `t=<unix seconds>,v1=<lowercase hex>`.
41const SIGNATURE_HEADER: &str = "Cratefield-Signature";
42
43/// `Tracker` over a tenant-configured webhook URL.
44///
45/// No `Debug` derive: the struct holds the shared signing secret, and a
46/// derived `Debug` would print it wherever a log line or a panic message
47/// met the adapter.
48pub struct WebhookTracker {
49    http: Arc<dyn HttpClient>,
50    /// Stamps the payload and the signature. A constructor argument rather
51    /// than a builder default so a deployment that forgets it fails to
52    /// compile instead of silently signing with a different time than it
53    /// serialised.
54    clock: Arc<dyn Clock>,
55    secret: String,
56}
57
58impl std::fmt::Debug for WebhookTracker {
59    /// Never prints the signing secret.
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("WebhookTracker")
62            .field("secret", &"[redacted]")
63            // The http and clock ports are struct fields too; nothing about
64            // either belongs in a log line.
65            .finish_non_exhaustive()
66    }
67}
68
69impl WebhookTracker {
70    /// An adapter for one shared signing secret — the same secret the
71    /// receiver verifies the `Cratefield-Signature` header with.
72    pub fn new(
73        http: Arc<dyn HttpClient>,
74        clock: Arc<dyn Clock>,
75        secret: impl Into<String>,
76    ) -> Self {
77        Self {
78            http,
79            clock,
80            secret: secret.into(),
81        }
82    }
83
84    /// Maps a receiver status onto the port's error taxonomy. The table is
85    /// the issue's, the same one the GitHub Issues adapter applies:
86    /// `401`/`403` say the shared secret is wrong; `404` and `422` say the
87    /// endpoint is gone or refused the ticket itself — neither is fixed by
88    /// retrying; `429` and the `5xx` family are the receiver having a
89    /// moment. For the rest, the defensible rule is that a `4xx` is a fact
90    /// about *our* request (`Rejected`) and nothing else is: a stray `3xx`
91    /// a proxy produced is weather, not a verdict on the draft.
92    fn map_status(
93        status: StatusCode,
94        body: &str,
95        retry_after: Option<std::time::Duration>,
96    ) -> TrackerError {
97        let detail = match serde_json::from_str::<ErrorResponse>(body) {
98            Ok(parsed) if !parsed.message.is_empty() => parsed.message,
99            _ => body.to_string(),
100        };
101        match status {
102            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
103            StatusCode::NOT_FOUND | StatusCode::UNPROCESSABLE_ENTITY => {
104                TrackerError::Rejected(format!("webhook {status}: {detail}"))
105            }
106            // `429` and the `5xx` family are the receiver having a moment:
107            // retry, and not before `retry_after` where it named one.
108            // (`retry_after` reads both header forms and answers `None` when
109            // no header came, so a bare 500 stays unscheduled.)
110            status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
111                TrackerError::Transient { retry_after }
112            }
113            // The rest of the `4xx` family reads the same way as `404` and
114            // `422`: a fact about our request, which a retry will not fix.
115            status if status.is_client_error() => {
116                TrackerError::Rejected(format!("webhook {status}: {detail}"))
117            }
118            // A stray `3xx` an unfollowing proxy produced is weather, not a
119            // verdict on the draft.
120            _ => TrackerError::Transient { retry_after },
121        }
122    }
123}
124
125#[async_trait]
126impl Tracker for WebhookTracker {
127    async fn file(
128        &self,
129        dest: &Destination,
130        _cred: &Credential,
131        draft: &TicketDraft,
132    ) -> Result<Filed, TrackerError> {
133        // The destination is the caller's now, not the draft's: which
134        // tracker to file into is tenant data (#453). The shared secret
135        // stays the adapter's, because it signs the payload rather than
136        // authenticating to a tracker — `cred` names no webhook.
137        let url = match dest {
138            Destination::Webhook { url } => url,
139            // `#[non_exhaustive]`: every other destination, including any
140            // a later core adds, is refused by name rather than silently
141            // doing nothing.
142            other => return Err(TrackerError::unsupported_destination(other)),
143        };
144
145        // One read of the clock stamps both the payload and the signature,
146        // so the two cannot disagree. (`std::time::SystemTime` is not
147        // available here: this adapter runs on Workers, and the `Clock`
148        // port is the only timer it is allowed.)
149        let signed_at = self.clock.now().unix_timestamp();
150        let payload = TicketPayload {
151            title: &draft.title,
152            body: &draft.body_markdown,
153            labels: &draft.labels,
154            idempotency_key: &draft.idempotency_key,
155            filed_at: signed_at,
156        };
157        // Built once: the exact bytes signed are the exact bytes sent. A
158        // re-serialisation between signing and sending would be a gap a
159        // verifier could fall into.
160        let raw_body = serde_json::to_vec(&payload)
161            .map_err(|_| TrackerError::Transient { retry_after: None })?;
162
163        let request = Request::builder()
164            .method(http::Method::POST)
165            .uri(url.as_str())
166            .header(CONTENT_TYPE, "application/json")
167            .header(
168                SIGNATURE_HEADER,
169                signature_header(&self.secret, signed_at, &raw_body)?,
170            )
171            .body(Bytes::from(raw_body))
172            .map_err(|_| TrackerError::Transient { retry_after: None })?;
173        let response = self.http.send(request).await.map_err(|err: HttpError| {
174            match err {
175                // The port's SSRF vetting refused the tenant-configured URL
176                // (a scheme, userinfo, or a loopback / private / link-local /
177                // metadata destination, re-vetted per redirect hop). That is
178                // a config error, not weather: retrying will not fix it.
179                HttpError::BlockedDestination(detail) => {
180                    TrackerError::Rejected(format!("webhook destination refused: {detail}"))
181                }
182                // Any other transport failure is weather — retry.
183                // `Transient` carries only the delay, so the transport's
184                // text goes to the log rather than being dropped.
185                other => {
186                    tracing::warn!(
187                        provider = "webhook",
188                        outcome = "failed",
189                        error = %other,
190                        "tracker outcome"
191                    );
192                    TrackerError::Transient { retry_after: None }
193                }
194            }
195        })?;
196
197        let status = response.status();
198        if !status.is_success() {
199            // One parser for both `Retry-After` forms (issue #214/#278); the
200            // date form needs the clock this adapter is constructed with.
201            let text = String::from_utf8_lossy(response.body()).to_string();
202            let delay = retry_after(response.headers(), self.clock.as_ref());
203            return Err(Self::map_status(status, &text, delay));
204        }
205
206        // A webhook answers "received", not "here is your ticket": there is
207        // no tracker-side id to learn back, so the idempotency key *is* the
208        // id, and `deduplicated` is `false` — whether the receiver created
209        // the ticket or answered a repeat delivery from its own dedupe is
210        // its knowledge, not ours.
211        tracing::info!(
212            provider = "webhook",
213            outcome = "accepted",
214            idempotency = %draft.idempotency_key,
215            "tracker outcome"
216        );
217        // A webhook mints no id of its own, so the key this adapter sent
218        // is the only handle there is, and the URL filed to is the only
219        // place it went.
220        Ok(Filed {
221            external_id: draft.idempotency_key.clone(),
222            url: url.clone(),
223        })
224    }
225
226    /// A webhook has nothing to read back.
227    ///
228    /// `file` POSTs to a URL and the receiver answers with an HTTP status,
229    /// not a ticket: there is no id on the far side to ask about later, and
230    /// `Filed::external_id` carries the idempotency key this adapter sent
231    /// rather than a handle the receiver minted. Inventing a state here —
232    /// "open", because nothing said otherwise — would make the one method
233    /// callers use to check a ticket lie in exactly the case it exists for.
234    ///
235    /// So it refuses, and says which capability is missing. A venture that
236    /// needs `status` wires a tracker that has tickets; `RoutingTracker`
237    /// exists so it can have both.
238    async fn status(
239        &self,
240        _dest: &Destination,
241        _cred: &Credential,
242        _external_id: &str,
243    ) -> Result<TicketStatus, TrackerError> {
244        Err(TrackerError::Rejected(
245            "a webhook files but does not track: the receiver returns no ticket to read back"
246                .to_owned(),
247        ))
248    }
249}
250
251/// The ticket as the webhook carries it: the draft's own fields, the
252/// idempotency key the receiver dedupes on, and the filing timestamp the
253/// signature binds.
254#[derive(serde::Serialize)]
255struct TicketPayload<'a> {
256    title: &'a str,
257    body: &'a str,
258    labels: &'a [String],
259    idempotency_key: &'a str,
260    /// Unix seconds at filing — the same instant that stamps the
261    /// `Cratefield-Signature` header.
262    filed_at: i64,
263}
264
265/// Signs `body` at `signed_at` and renders the `Cratefield-Signature`
266/// header value: `t=<unix seconds>,v1=<lowercase hex>`, Stripe-style, both
267/// parts in one header.
268///
269/// The MAC is HMAC-SHA256 over the bytes `{signed_at}.{body}` — the
270/// timestamp is bound INTO the signature, so a captured delivery cannot be
271/// replayed with a fresh timestamp: the re-stamp invalidates the MAC. That
272/// is the reason for this scheme over a separate, unsigned timestamp
273/// header, which a replay would carry untouched.
274fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Result<String, TrackerError> {
275    // HMAC-SHA256 over `{t}.{body}` — the in-repo idiom, as in the Stripe
276    // adapter's own webhook verification.
277    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
278        .map_err(|_| TrackerError::Transient { retry_after: None })?;
279    mac.update(signed_at.to_string().as_bytes());
280    mac.update(b".");
281    mac.update(body);
282    let mac_hex = lower_hex(&mac.finalize().into_bytes());
283    Ok(format!("t={signed_at},v1={mac_hex}"))
284}
285
286/// Lowercase hex, the case the Stripe-style verifiers expect.
287fn lower_hex(bytes: &[u8]) -> String {
288    let mut out = String::with_capacity(bytes.len() * 2);
289    for byte in bytes {
290        // Writing to a String cannot fail.
291        let _ = write!(out, "{byte:02x}");
292    }
293    out
294}
295
296#[derive(serde::Deserialize)]
297struct ErrorResponse {
298    #[serde(default)]
299    message: String,
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
308        let header =
309            signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
310        assert!(header.starts_with("t=1234,v1="), "{header}");
311        let hex = header.trim_start_matches("t=1234,v1=");
312        assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
313        assert!(
314            hex.chars()
315                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
316            "the hex is lowercase: {header}"
317        );
318    }
319
320    #[test]
321    fn debug_of_the_adapter_redacts_the_secret() {
322        let adapter = WebhookTracker::new(
323            Arc::new(NoHttp),
324            Arc::new(cratefield_core::SystemClock),
325            "whsec_super_secret_signing_key",
326        );
327        let printed = format!("{adapter:?}");
328        assert!(printed.contains("WebhookTracker"), "{printed}");
329        assert!(
330            !printed.contains("whsec_super_secret_signing_key"),
331            "{printed}"
332        );
333        assert!(printed.contains("[redacted]"), "{printed}");
334    }
335
336    /// The transport is never reached by the `Debug` test above.
337    struct NoHttp;
338
339    #[async_trait]
340    impl HttpClient for NoHttp {
341        async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
342            Err(HttpError::Transport("not used".to_owned()))
343        }
344    }
345}