Skip to main content

teamcraft/
webhooks.rs

1//! Delivery of outgoing event webhooks.
2//!
3//! The services write one row per matching webhook into `webhook_deliveries`
4//! inside the transaction of the event (`services::webhooks::emit`), so a
5//! delivery exists exactly when the event was committed. The background task
6//! started by [`spawn`] sends the due rows:
7//!
8//! * `POST <url>` with the JSON payload and the headers `X-TeamCraft-Event`,
9//!   `X-TeamCraft-Delivery` (the row id, the same for every retry) and
10//!   `X-TeamCraft-Signature: sha256=<hex HMAC-SHA256(secret, body)>`;
11//! * a 2xx response is success; anything else (including a redirect, which is
12//!   never followed, and a timeout after [`TIMEOUT`]) is retried with
13//!   exponential backoff and jitter ([`retry_delay`]) up to [`MAX_ATTEMPTS`];
14//! * a row is claimed with `FOR UPDATE SKIP LOCKED` and leased for
15//!   [`LEASE`], so several servers never send the same attempt and a crashed
16//!   sender's row is retried after the lease;
17//! * response bodies are neither read nor stored, only the status code.
18//!
19//! **Target guard (SSRF).** Only `http` and `https` URLs without user
20//! credentials are accepted. Every address a host name resolves to is checked
21//! when the connection is made (a custom resolver, so DNS rebinding cannot
22//! slip through) and IP literals are checked before: private, loopback,
23//! link-local (cloud metadata), shared and unique-local addresses are
24//! refused unless the host is listed in `TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS` or
25//! `TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE=true`. A non-empty allow-list also
26//! restricts webhooks to the listed hosts. Proxy variables are ignored so the
27//! check sees the real target.
28
29use std::{
30    future::Future,
31    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
32    pin::Pin,
33    sync::Arc,
34    time::Duration,
35};
36
37use serde_json::Value;
38use sha2::{Digest, Sha256};
39use sqlx::PgPool;
40use uuid::Uuid;
41
42use crate::{
43    config::{Config, WebhookConfig},
44    domain,
45    error::AppResult,
46};
47
48/// Attempts per delivery before it is marked `failed`.
49pub const MAX_ATTEMPTS: i32 = 8;
50/// Time limit of one HTTP request (connect, send, response head).
51pub const TIMEOUT: Duration = Duration::from_secs(10);
52/// How long a claimed delivery is reserved for its sender.
53pub const LEASE: Duration = Duration::from_secs(120);
54/// Delay before the first retry; it doubles with every further attempt.
55pub const FIRST_RETRY: Duration = Duration::from_secs(60);
56/// Longest delay between two attempts.
57pub const MAX_RETRY: Duration = Duration::from_secs(6 * 3600);
58/// Deliveries sent concurrently by one run.
59const BATCH: i64 = 25;
60/// Pause between two runs of the background task.
61const INTERVAL: Duration = Duration::from_secs(5);
62/// Longest `last_error` stored (characters).
63const MAX_ERROR_CHARS: usize = 500;
64
65// ---------------------------------------------------------------------------
66// Signature
67// ---------------------------------------------------------------------------
68
69/// HMAC-SHA256 (RFC 2104) on top of `sha2`.
70pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
71    const BLOCK: usize = 64;
72    let mut block = [0u8; BLOCK];
73    if key.len() > BLOCK {
74        block[..32].copy_from_slice(&Sha256::digest(key));
75    } else {
76        block[..key.len()].copy_from_slice(key);
77    }
78    let mut ipad = [0x36u8; BLOCK];
79    let mut opad = [0x5cu8; BLOCK];
80    for i in 0..BLOCK {
81        ipad[i] ^= block[i];
82        opad[i] ^= block[i];
83    }
84    let inner = Sha256::new()
85        .chain_update(ipad)
86        .chain_update(message)
87        .finalize();
88    Sha256::new()
89        .chain_update(opad)
90        .chain_update(inner)
91        .finalize()
92        .into()
93}
94
95/// Value of the `X-TeamCraft-Signature` header: `sha256=<hex>`; the key is
96/// the webhook secret as UTF-8 bytes.
97pub fn signature(secret: &str, body: &[u8]) -> String {
98    format!(
99        "sha256={}",
100        hex::encode(hmac_sha256(secret.as_bytes(), body))
101    )
102}
103
104/// Checks a signature header in constant time (what a receiver does).
105pub fn verify_signature(secret: &str, body: &[u8], header: &str) -> bool {
106    let expected = signature(secret, body);
107    let header = header.trim().as_bytes();
108    if header.len() != expected.len() {
109        return false;
110    }
111    header
112        .iter()
113        .zip(expected.as_bytes())
114        .fold(0u8, |acc, (a, b)| acc | (a ^ b))
115        == 0
116}
117
118// ---------------------------------------------------------------------------
119// Backoff
120// ---------------------------------------------------------------------------
121
122/// Delay after the failed attempt number `attempts` (1-based): one minute,
123/// doubling per attempt, capped at [`MAX_RETRY`], plus 0–25 % jitter derived
124/// from the delivery id (deterministic, so the schedule of one delivery is
125/// reproducible while many deliveries do not retry in lockstep).
126pub fn retry_delay(id: Uuid, attempts: i32) -> Duration {
127    let exponent = u32::try_from(attempts.max(1) - 1).unwrap_or(0).min(20);
128    let base = FIRST_RETRY.saturating_mul(1u32 << exponent).min(MAX_RETRY);
129    let bytes = id.as_bytes();
130    let seed = u64::from_le_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
131        ^ u64::from_le_bytes(bytes[8..].try_into().unwrap_or([0; 8]))
132        ^ (attempts as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
133    // splitmix64 finaliser: every bit of the seed affects the result
134    let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
135    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
136    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
137    z ^= z >> 31;
138    let jitter_permille = z % 251; // 0 ..= 250 ‰
139    base + base.mul_f64(jitter_permille as f64 / 1000.0)
140}
141
142// ---------------------------------------------------------------------------
143// Target guard
144// ---------------------------------------------------------------------------
145
146/// Which webhook targets are allowed (see the module documentation).
147#[derive(Clone, Debug, Default)]
148pub struct TargetPolicy {
149    allowed_hosts: Vec<String>,
150    allow_private: bool,
151}
152
153impl TargetPolicy {
154    pub fn new(config: &WebhookConfig) -> TargetPolicy {
155        TargetPolicy {
156            allowed_hosts: config.allowed_hosts.clone(),
157            allow_private: config.allow_private,
158        }
159    }
160
161    /// Whether the host is on the allow-list (exact or `*.suffix`).
162    fn listed(&self, host: &str) -> bool {
163        self.allowed_hosts
164            .iter()
165            .any(|entry| match entry.strip_prefix("*.") {
166                Some(suffix) => host
167                    .strip_suffix(suffix)
168                    .is_some_and(|rest| rest.ends_with('.') && rest.len() > 1),
169                None => entry == host,
170            })
171    }
172
173    /// Checks a webhook URL without resolving it: scheme, credentials, the
174    /// allow-list and IP literals. Returns the parsed URL.
175    pub fn check_url(&self, raw: &str) -> Result<reqwest::Url, String> {
176        let url =
177            reqwest::Url::parse(raw.trim()).map_err(|e| format!("The URL is not valid ({e})."))?;
178        if !matches!(url.scheme(), "http" | "https") {
179            return Err("Only http:// and https:// URLs can receive webhooks.".into());
180        }
181        if !url.username().is_empty() || url.password().is_some() {
182            return Err(
183                "User names and passwords in the URL are not supported; the receiver checks \
184                 the X-TeamCraft-Signature header instead."
185                    .into(),
186            );
187        }
188        let host = host_of(&url).ok_or("The URL has no host.")?;
189        if !self.allowed_hosts.is_empty() && !self.listed(&host) {
190            return Err(format!(
191                "{host} is not in TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS; ask the server administrator."
192            ));
193        }
194        if let Ok(ip) = host.parse::<IpAddr>() {
195            if !self.address_allowed(&host, ip) {
196                return Err(blocked_message(&host));
197            }
198        }
199        Ok(url)
200    }
201
202    /// Whether a connection from `host` to `ip` is allowed.
203    pub fn address_allowed(&self, host: &str, ip: IpAddr) -> bool {
204        let ip = canonical(ip);
205        if ip.is_unspecified() || ip.is_multicast() || is_broadcast(ip) {
206            return false;
207        }
208        !is_internal(ip) || self.allow_private || self.listed(host)
209    }
210}
211
212fn blocked_message(host: &str) -> String {
213    format!(
214        "{host} is a private, loopback or link-local address; webhooks may only reach it when the \
215         server administrator lists the host in TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS."
216    )
217}
218
219/// The host of a URL in lower case, IPv6 without brackets.
220fn host_of(url: &reqwest::Url) -> Option<String> {
221    let host = url
222        .host_str()?
223        .trim_start_matches('[')
224        .trim_end_matches(']')
225        .trim_end_matches('.')
226        .to_ascii_lowercase();
227    (!host.is_empty()).then_some(host)
228}
229
230/// IPv4-mapped and -compatible IPv6 addresses as IPv4.
231fn canonical(ip: IpAddr) -> IpAddr {
232    match ip {
233        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
234            Some(v4) => IpAddr::V4(v4),
235            None => {
236                let s = v6.segments();
237                if s[..6] == [0, 0, 0, 0, 0, 0] && !(s[6] == 0 && s[7] <= 1) {
238                    IpAddr::V4(Ipv4Addr::new(
239                        (s[6] >> 8) as u8,
240                        s[6] as u8,
241                        (s[7] >> 8) as u8,
242                        s[7] as u8,
243                    ))
244                } else {
245                    IpAddr::V6(v6)
246                }
247            }
248        },
249        v4 => v4,
250    }
251}
252
253fn is_broadcast(ip: IpAddr) -> bool {
254    matches!(ip, IpAddr::V4(v4) if v4.is_broadcast())
255}
256
257/// Addresses inside an organisation or the host itself: loopback, private
258/// (RFC 1918), shared (100.64/10), link-local (incl. 169.254.169.254),
259/// "this network" (0/8), benchmarking (198.18/15), IPv6 unique-local
260/// (fc00::/7), link-local (fe80::/10) and site-local (fec0::/10), and NAT64
261/// and 6to4 addresses of such IPv4 addresses.
262pub fn is_internal(ip: IpAddr) -> bool {
263    match canonical(ip) {
264        IpAddr::V4(v4) => {
265            let o = v4.octets();
266            v4.is_loopback()
267                || v4.is_private()
268                || v4.is_link_local()
269                || o[0] == 0
270                || (o[0] == 100 && (o[1] & 0xc0) == 64)
271                || (o[0] == 198 && (o[1] & 0xfe) == 18)
272                || (o[0] == 192 && o[1] == 0 && o[2] == 0)
273        }
274        IpAddr::V6(v6) => {
275            let s = v6.segments();
276            let v4_at = |hi: u16, lo: u16| {
277                is_internal(IpAddr::V4(Ipv4Addr::new(
278                    (hi >> 8) as u8,
279                    hi as u8,
280                    (lo >> 8) as u8,
281                    lo as u8,
282                )))
283            };
284            v6.is_loopback()
285                || (s[0] & 0xfe00) == 0xfc00
286                || (s[0] & 0xffc0) == 0xfe80
287                || (s[0] & 0xffc0) == 0xfec0
288                // NAT64 well-known prefix 64:ff9b::/96 …
289                || (s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] && v4_at(s[6], s[7]))
290                // … and the local-use prefix 64:ff9b:1::/48 (RFC 8215)
291                || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 && v4_at(s[6], s[7]))
292                // 6to4 (2002:V4ADDR::/48) of an internal IPv4 address
293                || (s[0] == 0x2002 && v4_at(s[1], s[2]))
294                || v6 == Ipv6Addr::UNSPECIFIED
295        }
296    }
297}
298
299/// Name resolution for webhook connections: host name → addresses. The
300/// system resolver in production ([`system_lookup`]); tests pass their own
301/// answers to [`Dispatcher::with_lookup`] (DNS rebinding without a DNS
302/// server).
303pub type Lookup = Arc<
304    dyn Fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<IpAddr>>> + Send>>
305        + Send
306        + Sync,
307>;
308
309/// The operating system's resolver.
310pub fn system_lookup() -> Lookup {
311    Arc::new(|host: String| {
312        Box::pin(async move {
313            Ok(tokio::net::lookup_host((host.as_str(), 0))
314                .await?
315                .map(|a| a.ip())
316                .collect())
317        })
318    })
319}
320
321/// DNS resolution that drops the addresses the policy does not allow; the
322/// HTTP client only ever connects to what this returns. It runs for every new
323/// connection, so an answer that changes between the check and the connect
324/// (DNS rebinding) is checked as it is used.
325struct GuardedResolver {
326    policy: Arc<TargetPolicy>,
327    lookup: Lookup,
328}
329
330impl reqwest::dns::Resolve for GuardedResolver {
331    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
332        let policy = self.policy.clone();
333        let host = name.as_str().trim_end_matches('.').to_ascii_lowercase();
334        let lookup = (self.lookup)(host.clone());
335        Box::pin(async move {
336            let found: Vec<SocketAddr> = lookup
337                .await?
338                .into_iter()
339                .map(|ip| SocketAddr::new(ip, 0))
340                .collect();
341            let allowed: Vec<SocketAddr> = found
342                .iter()
343                .copied()
344                .filter(|a| policy.address_allowed(&host, a.ip()))
345                .collect();
346            if allowed.is_empty() {
347                let message = if found.is_empty() {
348                    format!("{host} has no address")
349                } else {
350                    blocked_message(&host)
351                };
352                return Err(Box::new(std::io::Error::other(message))
353                    as Box<dyn std::error::Error + Send + Sync>);
354            }
355            Ok(Box::new(allowed.into_iter()) as reqwest::dns::Addrs)
356        })
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Payload
362// ---------------------------------------------------------------------------
363
364/// URLs are stored relative (`/revisions/<id>`, `/api/v1/…`) and made
365/// absolute with the public URL when they are sent, so a changed
366/// `TEAMCRAFT_PUBLIC_URL` applies to pending deliveries as well.
367pub fn absolutize(value: &mut Value, public_url: &str) {
368    match value {
369        Value::Object(map) => {
370            for (key, v) in map.iter_mut() {
371                match v {
372                    Value::String(s) if key.ends_with("_url") && s.starts_with('/') => {
373                        *s = format!("{public_url}{s}");
374                    }
375                    _ => absolutize(v, public_url),
376                }
377            }
378        }
379        Value::Array(items) => items.iter_mut().for_each(|v| absolutize(v, public_url)),
380        _ => {}
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Sending
386// ---------------------------------------------------------------------------
387
388/// A delivery claimed by this sender.
389#[derive(sqlx::FromRow)]
390struct Claimed {
391    id: Uuid,
392    event: String,
393    payload: Value,
394    attempts: i32,
395    /// The end of this sender's lease (`next_attempt_at` set by the claim):
396    /// the token that lets only this attempt record its result. A "Retry"
397    /// or a new claim after the lease moves it, so a late result of an
398    /// older attempt cannot overwrite the newer one (`attempts` alone can
399    /// repeat after "Retry" reset it).
400    lease_until: chrono::DateTime<chrono::Utc>,
401    url: String,
402    secret: String,
403}
404
405/// The result of one attempt.
406#[derive(Clone, Debug, PartialEq, Eq)]
407pub struct Outcome {
408    pub delivery_id: Uuid,
409    /// `sent`, `pending` (will be retried) or `failed`.
410    pub status: String,
411    pub attempts: i32,
412    pub status_code: Option<u16>,
413    pub error: String,
414}
415
416/// What one run did.
417#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
418pub struct Report {
419    pub sent: u64,
420    pub retrying: u64,
421    pub failed: u64,
422}
423
424/// Sends webhook deliveries (cheap to clone).
425#[derive(Clone)]
426pub struct Dispatcher {
427    http: reqwest::Client,
428    policy: Arc<TargetPolicy>,
429    public_url: String,
430}
431
432const CLAIM: &str = "
433    WITH due AS (
434        SELECT d.id FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id
435        WHERE d.status = 'pending' AND w.active AND {filter} AND NOT {withheld}
436        ORDER BY d.next_attempt_at
437        LIMIT $1
438        FOR UPDATE OF d SKIP LOCKED
439    )
440    UPDATE webhook_deliveries d
441    SET attempts = d.attempts + 1, next_attempt_at = now() + make_interval(secs => $2),
442        updated_at = now()
443    FROM due, webhooks w
444    WHERE d.id = due.id AND w.id = d.webhook_id
445    RETURNING d.id, d.event, d.payload, d.attempts, d.next_attempt_at AS lease_until,
446              w.url, w.secret";
447
448/// SQL condition on a delivery `d` of webhook `w`: one of the event's
449/// project groups is restricted now and is not the webhook's own group. The
450/// webhook may no longer read the event (the group was restricted after the
451/// delivery was queued), or a group of the event switched webhooks off, so it
452/// is not sent.
453const WITHHELD: &str = "(EXISTS (SELECT 1 FROM projects rp
454                                 WHERE rp.id = ANY(d.projects) AND rp.visibility = 'restricted'
455                                   AND rp.id IS DISTINCT FROM w.project_id)
456    OR EXISTS (SELECT 1 FROM project_policies wp
457                                 JOIN projects wpp ON wpp.id = wp.project_id
458                                 WHERE wp.project_id = ANY(d.projects) AND wpp.visibility = 'restricted'
459                                   AND NOT wp.webhooks))";
460
461/// The text recorded on a withheld delivery.
462pub const WITHHELD_ERROR: &str =
463    "Not sent: a project group of the event was restricted, or switched webhooks off, after it was queued.";
464
465fn claim_sql(filter: &str) -> String {
466    CLAIM
467        .replace("{filter}", filter)
468        .replace("{withheld}", WITHHELD)
469}
470
471/// Marks the pending deliveries the webhook may no longer read as failed
472/// (see [`WITHHELD`]); returns how many.
473async fn withhold(pool: &PgPool) -> AppResult<u64> {
474    Ok(sqlx::query(&format!(
475        "UPDATE webhook_deliveries d
476         SET status = 'failed', last_error = $1, updated_at = now()
477         FROM webhooks w
478         WHERE w.id = d.webhook_id AND d.status = 'pending' AND {WITHHELD}"
479    ))
480    .bind(WITHHELD_ERROR)
481    .execute(pool)
482    .await?
483    .rows_affected())
484}
485
486impl Dispatcher {
487    pub fn from_config(config: &Config) -> anyhow::Result<Dispatcher> {
488        Dispatcher::new(&config.public_url, &config.webhooks)
489    }
490
491    pub fn new(public_url: &str, config: &WebhookConfig) -> anyhow::Result<Dispatcher> {
492        Dispatcher::with_lookup(public_url, config, system_lookup())
493    }
494
495    /// A dispatcher whose connections resolve host names with `lookup`
496    /// (tests; [`Dispatcher::new`] uses the system resolver).
497    pub fn with_lookup(
498        public_url: &str,
499        config: &WebhookConfig,
500        lookup: Lookup,
501    ) -> anyhow::Result<Dispatcher> {
502        let policy = Arc::new(TargetPolicy::new(config));
503        let http = reqwest::Client::builder()
504            .timeout(TIMEOUT)
505            .connect_timeout(Duration::from_secs(5))
506            .redirect(reqwest::redirect::Policy::none())
507            .no_proxy()
508            .dns_resolver(Arc::new(GuardedResolver {
509                policy: policy.clone(),
510                lookup,
511            }))
512            .user_agent(concat!("TeamCraft-Webhooks/", env!("CARGO_PKG_VERSION")))
513            .build()?;
514        Ok(Dispatcher {
515            http,
516            policy,
517            public_url: public_url.trim_end_matches('/').to_string(),
518        })
519    }
520
521    pub fn policy(&self) -> &TargetPolicy {
522        &self.policy
523    }
524
525    /// Claims up to `limit` due deliveries (`FOR UPDATE SKIP LOCKED`).
526    async fn claim(&self, pool: &PgPool, limit: i64) -> AppResult<Vec<Claimed>> {
527        Ok(
528            sqlx::query_as::<_, Claimed>(&claim_sql("d.next_attempt_at <= now()"))
529                .bind(limit)
530                .bind(LEASE.as_secs_f64())
531                .fetch_all(pool)
532                .await?,
533        )
534    }
535
536    /// Sends the due deliveries (up to `BATCH` at once).
537    pub async fn run_once(&self, pool: &PgPool) -> AppResult<Report> {
538        let mut report = Report {
539            failed: withhold(pool).await?,
540            ..Report::default()
541        };
542        let claimed = self.claim(pool, BATCH).await?;
543        let mut tasks = tokio::task::JoinSet::new();
544        for c in claimed {
545            let (me, pool) = (self.clone(), pool.clone());
546            tasks.spawn(async move { me.attempt(&pool, c).await });
547        }
548        while let Some(joined) = tasks.join_next().await {
549            match joined {
550                Ok(Ok(outcome)) => match outcome.status.as_str() {
551                    "sent" => report.sent += 1,
552                    "failed" => report.failed += 1,
553                    _ => report.retrying += 1,
554                },
555                Ok(Err(e)) => tracing::warn!(error = %e, "recording a webhook delivery failed"),
556                Err(e) => tracing::warn!(error = %e, "webhook delivery task failed"),
557            }
558        }
559        Ok(report)
560    }
561
562    /// Sends one due delivery now ("Send test", "Retry" – both make it due
563    /// at once). `None` if it is not pending, or another sender holds it:
564    /// a claimed row is leased (`next_attempt_at` in the future) and must
565    /// not be sent a second time while that attempt may still be running.
566    pub async fn deliver_now(
567        &self,
568        pool: &PgPool,
569        delivery_id: Uuid,
570    ) -> AppResult<Option<Outcome>> {
571        withhold(pool).await?;
572        let claimed =
573            sqlx::query_as::<_, Claimed>(&claim_sql("d.id = $3 AND d.next_attempt_at <= now()"))
574                .bind(1i64)
575                .bind(LEASE.as_secs_f64())
576                .bind(delivery_id)
577                .fetch_optional(pool)
578                .await?;
579        match claimed {
580            Some(c) => Ok(Some(self.attempt(pool, c).await?)),
581            None => Ok(None),
582        }
583    }
584
585    /// One HTTP attempt and its result in the outbox.
586    async fn attempt(&self, pool: &PgPool, c: Claimed) -> AppResult<Outcome> {
587        let (code, error) = match self.post(&c).await {
588            Ok(code) if (200..300).contains(&code) => (Some(code), String::new()),
589            Ok(code) if (300..400).contains(&code) => (
590                Some(code),
591                format!("HTTP {code}: redirects are not followed – use the final URL"),
592            ),
593            Ok(code) => (Some(code), format!("HTTP {code}")),
594            Err(e) => (None, e),
595        };
596        let error = domain::truncate_chars(&error, MAX_ERROR_CHARS);
597        let status = if error.is_empty() {
598            "sent"
599        } else if c.attempts >= MAX_ATTEMPTS {
600            "failed"
601        } else {
602            "pending"
603        };
604        let delay = retry_delay(c.id, c.attempts);
605        // Only the holder of this attempt records it: a lease that ran out
606        // and was claimed again, or a "Retry" in between, moved
607        // next_attempt_at away from this attempt's lease.
608        sqlx::query(
609            "UPDATE webhook_deliveries
610             SET status = $3, last_status_code = $4, last_error = $5, updated_at = now(),
611                 next_attempt_at = CASE WHEN $3 = 'pending' THEN now() + make_interval(secs => $6)
612                                        ELSE next_attempt_at END,
613                 delivered_at = CASE WHEN $3 = 'sent' THEN now() ELSE delivered_at END
614             WHERE id = $1 AND attempts = $2 AND next_attempt_at = $7 AND status = 'pending'",
615        )
616        .bind(c.id)
617        .bind(c.attempts)
618        .bind(status)
619        .bind(code.map(i32::from))
620        .bind(&error)
621        .bind(delay.as_secs_f64())
622        .bind(c.lease_until)
623        .execute(pool)
624        .await?;
625        if !error.is_empty() {
626            tracing::warn!(delivery = %c.id, event = %c.event, attempts = c.attempts, status, error = %error, "webhook delivery failed");
627        }
628        Ok(Outcome {
629            delivery_id: c.id,
630            status: status.to_string(),
631            attempts: c.attempts,
632            status_code: code,
633            error,
634        })
635    }
636
637    /// POSTs the signed payload; returns the response status.
638    async fn post(&self, c: &Claimed) -> Result<u16, String> {
639        // The rules may have become stricter since the webhook was saved.
640        let url = self.policy.check_url(&c.url)?;
641        let mut payload = c.payload.clone();
642        absolutize(&mut payload, &self.public_url);
643        let body = serde_json::to_vec(&payload).map_err(|e| e.to_string())?;
644        let response = self
645            .http
646            .post(url)
647            .header(reqwest::header::CONTENT_TYPE, "application/json")
648            .header("X-TeamCraft-Event", &c.event)
649            .header("X-TeamCraft-Delivery", c.id.to_string())
650            .header("X-TeamCraft-Signature", signature(&c.secret, &body))
651            .body(body)
652            .send()
653            .await
654            .map_err(describe)?;
655        // The body is not read: dropping the response closes the stream.
656        Ok(response.status().as_u16())
657    }
658}
659
660/// A transport error without the URL (it may carry a token in its query).
661fn describe(e: reqwest::Error) -> String {
662    let timeout = e.is_timeout();
663    let connect = e.is_connect();
664    let e = e.without_url();
665    let mut parts = vec![e.to_string()];
666    let mut source = std::error::Error::source(&e);
667    while let Some(s) = source {
668        let text = s.to_string();
669        if !parts.iter().any(|p| p.contains(&text)) {
670            parts.push(text);
671        }
672        source = s.source();
673    }
674    let detail = parts.join(": ");
675    if timeout {
676        format!("no response within {} s ({detail})", TIMEOUT.as_secs())
677    } else if connect {
678        format!("connection failed: {detail}")
679    } else {
680        detail
681    }
682}
683
684/// Removes sent and failed deliveries older than `days`. Returns the count.
685///
686/// First gives up deliveries still pending for an inactive webhook: an event
687/// whose transaction read the webhook as active while an administrator
688/// switched it off can commit a row after the switch gave up the waiting
689/// ones. The sender never claims such rows, so without this they would stay
690/// pending (and in the sender's index) for ever.
691pub async fn purge_finished(pool: &PgPool, days: u32) -> AppResult<u64> {
692    sqlx::query(
693        "UPDATE webhook_deliveries d
694         SET status = 'failed', last_error = 'The webhook was deactivated.', updated_at = now()
695         FROM webhooks w
696         WHERE w.id = d.webhook_id AND NOT w.active AND d.status = 'pending'",
697    )
698    .execute(pool)
699    .await?;
700    let mut total = 0;
701    loop {
702        let removed = sqlx::query(
703            "DELETE FROM webhook_deliveries WHERE id IN (
704                 SELECT id FROM webhook_deliveries
705                 WHERE status <> 'pending' AND updated_at < now() - make_interval(days => $1)
706                 LIMIT 5000)",
707        )
708        .bind(i32::try_from(days).unwrap_or(i32::MAX))
709        .execute(pool)
710        .await?
711        .rows_affected();
712        total += removed;
713        if removed < 5000 {
714            return Ok(total);
715        }
716    }
717}
718
719/// Starts the background delivery.
720pub fn spawn(pool: PgPool, dispatcher: Dispatcher) {
721    tokio::spawn(async move {
722        let mut interval = tokio::time::interval(INTERVAL);
723        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
724        loop {
725            interval.tick().await;
726            // A full batch means there may be more: continue at once.
727            loop {
728                match dispatcher.run_once(&pool).await {
729                    Ok(r) => {
730                        if r.sent + r.retrying + r.failed > 0 {
731                            tracing::info!(
732                                sent = r.sent,
733                                retrying = r.retrying,
734                                failed = r.failed,
735                                "delivered webhooks"
736                            );
737                        }
738                        if (r.sent + r.retrying + r.failed) < BATCH as u64 {
739                            break;
740                        }
741                    }
742                    Err(e) => {
743                        tracing::warn!(error = %e, "webhook delivery failed");
744                        break;
745                    }
746                }
747            }
748        }
749    });
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use serde_json::json;
756
757    fn hex_of(key: &[u8], data: &[u8]) -> String {
758        hex::encode(hmac_sha256(key, data))
759    }
760
761    /// RFC 4231, test cases 1–4, 6 and 7 (5 truncates the output).
762    #[test]
763    fn hmac_sha256_matches_rfc_4231() {
764        assert_eq!(
765            hex_of(&[0x0b; 20], b"Hi There"),
766            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
767        );
768        assert_eq!(
769            hex_of(b"Jefe", b"what do ya want for nothing?"),
770            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
771        );
772        assert_eq!(
773            hex_of(&[0xaa; 20], &[0xdd; 50]),
774            "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"
775        );
776        let key: Vec<u8> = (1..=25).collect();
777        assert_eq!(
778            hex_of(&key, &[0xcd; 50]),
779            "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b"
780        );
781        assert_eq!(
782            hex_of(
783                &[0xaa; 131],
784                b"Test Using Larger Than Block-Size Key - Hash Key First"
785            ),
786            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
787        );
788        assert_eq!(
789            hex_of(
790                &[0xaa; 131],
791                b"This is a test using a larger than block-size key and a larger than block-size data. \
792                  The key needs to be hashed before being used by the HMAC algorithm."
793            ),
794            "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2"
795        );
796    }
797
798    #[test]
799    fn signature_header() {
800        let body = br#"{"event":"ping"}"#;
801        let sig = signature("tc_whsec_test", body);
802        assert!(sig.starts_with("sha256=") && sig.len() == 7 + 64);
803        assert_eq!(
804            sig,
805            format!("sha256={}", hex_of(b"tc_whsec_test", body)),
806            "the key is the secret as text"
807        );
808        assert!(verify_signature("tc_whsec_test", body, &sig));
809        assert!(!verify_signature("tc_whsec_other", body, &sig));
810        assert!(!verify_signature("tc_whsec_test", b"{}", &sig));
811        assert!(!verify_signature("tc_whsec_test", body, "sha256=00"));
812    }
813
814    #[test]
815    fn backoff_doubles_with_bounded_jitter() {
816        let id = Uuid::parse_str("6f1b9c1e-2d43-4b8e-9a51-0c7d2e3f4a5b").unwrap();
817        let mut previous = Duration::ZERO;
818        for attempt in 1..MAX_ATTEMPTS {
819            let delay = retry_delay(id, attempt);
820            let base = FIRST_RETRY * (1 << (attempt - 1));
821            assert!(
822                delay >= base && delay <= base.mul_f64(1.25),
823                "attempt {attempt}: {delay:?}"
824            );
825            assert!(delay > previous, "grows");
826            assert_eq!(delay, retry_delay(id, attempt), "deterministic");
827            previous = delay;
828        }
829        assert!(retry_delay(id, 50) <= MAX_RETRY.mul_f64(1.25), "capped");
830        let spread: std::collections::HashSet<Duration> =
831            (0..20).map(|_| retry_delay(Uuid::new_v4(), 3)).collect();
832        assert!(spread.len() > 10, "jitter differs between deliveries");
833    }
834
835    #[test]
836    fn internal_addresses() {
837        for ip in [
838            "127.0.0.1",
839            "10.1.2.3",
840            "172.16.0.1",
841            "192.168.1.1",
842            "169.254.169.254",
843            "100.64.0.1",
844            "0.0.0.1",
845            "198.18.0.1",
846            "::1",
847            "fd00::1",
848            "fe80::1",
849            "::ffff:127.0.0.1",
850            "::ffff:10.0.0.1",
851            "::10.0.0.1",
852            "64:ff9b::a00:1",
853            "64:ff9b:1::a9fe:a9fe",
854            "2002:7f00:1::1",
855            "2002:a9fe:a9fe::",
856        ] {
857            assert!(is_internal(ip.parse().unwrap()), "{ip} is internal");
858        }
859        for ip in [
860            "8.8.8.8",
861            "93.184.216.34",
862            "2001:4860:4860::8888",
863            "::ffff:1.1.1.1",
864            "2002:808:808::1",
865        ] {
866            assert!(!is_internal(ip.parse().unwrap()), "{ip} is public");
867        }
868    }
869
870    #[test]
871    fn default_policy_blocks_internal_targets() {
872        let p = TargetPolicy::default();
873        assert!(p
874            .check_url("https://ci.example.org/hooks/teamcraft?token=x")
875            .is_ok());
876        assert!(p.check_url("http://erp.example.org:8080/in").is_ok());
877        for bad in [
878            "ftp://example.org/x",
879            "file:///etc/passwd",
880            "https://user:pw@example.org/",
881            "http://127.0.0.1:8080/",
882            "http://127.1/",
883            "http://[::1]/",
884            "http://[::ffff:169.254.169.254]/",
885            "http://169.254.169.254/latest/meta-data/",
886            "http://0.0.0.0/",
887            "http://224.0.0.1/",
888            "not a url",
889        ] {
890            assert!(p.check_url(bad).is_err(), "{bad} is refused");
891        }
892        // name resolution decides for host names
893        let ip = "10.0.0.5".parse().unwrap();
894        assert!(!p.address_allowed("erp.internal", ip));
895        assert!(p.address_allowed("erp.internal", "203.0.113.9".parse().unwrap()));
896    }
897
898    /// A lookup that gives the next of `answers` on every call (the last one
899    /// once they run out) and counts the calls.
900    fn scripted(answers: &[&[&str]]) -> (Lookup, Arc<std::sync::atomic::AtomicUsize>) {
901        let answers: Vec<Vec<IpAddr>> = answers
902            .iter()
903            .map(|a| a.iter().map(|ip| ip.parse().unwrap()).collect())
904            .collect();
905        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
906        let counter = calls.clone();
907        let lookup: Lookup = Arc::new(move |_host: String| {
908            let n = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
909            let answer = answers[n.min(answers.len() - 1)].clone();
910            Box::pin(async move { Ok(answer) })
911        });
912        (lookup, calls)
913    }
914
915    async fn resolve(r: &GuardedResolver, host: &str) -> Result<Vec<IpAddr>, String> {
916        use reqwest::dns::Resolve;
917        match r.resolve(host.parse().unwrap()).await {
918            Ok(addrs) => Ok(addrs.map(|a| a.ip()).collect()),
919            Err(e) => Err(e.to_string()),
920        }
921    }
922
923    #[tokio::test]
924    async fn every_connection_checks_the_answer_it_uses() {
925        // DNS rebinding: the first answer is public, the next one loopback.
926        // The resolver runs for each connection and filters what it returns,
927        // so there is no gap between a check and the connect.
928        let (lookup, calls) = scripted(&[&["203.0.113.9"], &["127.0.0.1"]]);
929        let r = GuardedResolver {
930            policy: Arc::new(TargetPolicy::default()),
931            lookup,
932        };
933        let public: IpAddr = "203.0.113.9".parse().unwrap();
934        assert_eq!(resolve(&r, "hooks.example.org").await, Ok(vec![public]));
935        let rebound = resolve(&r, "hooks.example.org").await.unwrap_err();
936        assert!(rebound.contains("private, loopback"), "{rebound}");
937        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
938
939        // a mixed answer keeps only the public addresses – also internal
940        // addresses in IPv6 clothing
941        let (lookup, _) = scripted(&[&[
942            "127.0.0.1",
943            "203.0.113.9",
944            "::ffff:10.0.0.1",
945            "169.254.169.254",
946            "fd00::1",
947            "0.0.0.0",
948        ]]);
949        let r = GuardedResolver {
950            policy: Arc::new(TargetPolicy::default()),
951            lookup,
952        };
953        assert_eq!(resolve(&r, "Hooks.Example.org.").await, Ok(vec![public]));
954
955        // nothing but internal addresses: refused, and nothing is returned
956        let (lookup, _) = scripted(&[&["10.0.0.5", "::1"]]);
957        let r = GuardedResolver {
958            policy: Arc::new(TargetPolicy::default()),
959            lookup,
960        };
961        assert!(resolve(&r, "erp.internal").await.is_err());
962
963        // a listed host may be internal, still never the unspecified address
964        let (lookup, _) = scripted(&[&["10.0.0.5", "0.0.0.0"]]);
965        let r = GuardedResolver {
966            policy: Arc::new(TargetPolicy::new(&WebhookConfig {
967                allowed_hosts: vec!["erp.internal".into()],
968                allow_private: false,
969                retention_days: 30,
970            })),
971            lookup,
972        };
973        assert_eq!(
974            resolve(&r, "erp.internal").await,
975            Ok(vec!["10.0.0.5".parse().unwrap()])
976        );
977
978        // no answer at all
979        let (lookup, _) = scripted(&[&[]]);
980        let r = GuardedResolver {
981            policy: Arc::new(TargetPolicy::default()),
982            lookup,
983        };
984        let none = resolve(&r, "gone.example.org").await.unwrap_err();
985        assert!(none.contains("has no address"), "{none}");
986    }
987
988    #[test]
989    fn allow_list_restricts_and_trusts_hosts() {
990        let p = TargetPolicy::new(&WebhookConfig {
991            allowed_hosts: vec![
992                "erp.corp.example".into(),
993                "*.ci.example.org".into(),
994                "10.0.0.7".into(),
995            ],
996            allow_private: false,
997            retention_days: 30,
998        });
999        assert!(p.check_url("https://erp.corp.example/api").is_ok());
1000        assert!(p.check_url("https://ERP.corp.example./api").is_ok());
1001        assert!(p.check_url("https://build.ci.example.org/x").is_ok());
1002        assert!(p.check_url("http://10.0.0.7:9000/x").is_ok(), "listed IP");
1003        assert!(
1004            p.check_url("https://ci.example.org/x").is_err(),
1005            "the suffix itself is not listed"
1006        );
1007        assert!(p.check_url("https://evilci.example.org/x").is_err());
1008        assert!(p.check_url("https://example.com/").is_err(), "not listed");
1009        assert!(p.check_url("http://10.0.0.8/").is_err());
1010        let private = "192.168.10.10".parse().unwrap();
1011        assert!(
1012            p.address_allowed("erp.corp.example", private),
1013            "listed hosts may be internal"
1014        );
1015        assert!(!p.address_allowed("other.example", private));
1016        assert!(!p.address_allowed("erp.corp.example", "0.0.0.0".parse().unwrap()));
1017
1018        let open = TargetPolicy::new(&WebhookConfig {
1019            allowed_hosts: Vec::new(),
1020            allow_private: true,
1021            retention_days: 30,
1022        });
1023        assert!(open.check_url("http://127.0.0.1:3000/hook").is_ok());
1024        assert!(
1025            open.check_url("http://0.0.0.0/").is_err(),
1026            "never the unspecified address"
1027        );
1028    }
1029
1030    #[test]
1031    fn urls_become_absolute_when_sent() {
1032        let mut v = json!({
1033            "event": "revision.released",
1034            "revision": { "web_url": "/revisions/1", "api_url": "/api/v1/items/P/revisions/A", "name": "/x" },
1035            "revisions": [{ "web_url": "/revisions/2" }],
1036            "other_url": "https://already.example/abs",
1037        });
1038        absolutize(&mut v, "https://plm.example.org");
1039        assert_eq!(
1040            v["revision"]["web_url"],
1041            "https://plm.example.org/revisions/1"
1042        );
1043        assert_eq!(
1044            v["revision"]["api_url"],
1045            "https://plm.example.org/api/v1/items/P/revisions/A"
1046        );
1047        assert_eq!(v["revision"]["name"], "/x", "only *_url keys");
1048        assert_eq!(
1049            v["revisions"][0]["web_url"],
1050            "https://plm.example.org/revisions/2"
1051        );
1052        assert_eq!(v["other_url"], "https://already.example/abs");
1053    }
1054
1055    #[test]
1056    fn migration_lists_every_event() {
1057        let sql = include_str!("../migrations/0022_webhooks.sql");
1058        for e in domain::WebhookEvent::ALL {
1059            assert_eq!(
1060                sql.matches(&format!("'{}'", e.as_str())).count(),
1061                2,
1062                "{} is allowed for webhooks and deliveries",
1063                e.as_str()
1064            );
1065        }
1066    }
1067}