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}