1use 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
48pub const MAX_ATTEMPTS: i32 = 8;
50pub const TIMEOUT: Duration = Duration::from_secs(10);
52pub const LEASE: Duration = Duration::from_secs(120);
54pub const FIRST_RETRY: Duration = Duration::from_secs(60);
56pub const MAX_RETRY: Duration = Duration::from_secs(6 * 3600);
58const BATCH: i64 = 25;
60const INTERVAL: Duration = Duration::from_secs(5);
62const MAX_ERROR_CHARS: usize = 500;
64
65pub 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
95pub fn signature(secret: &str, body: &[u8]) -> String {
98 format!(
99 "sha256={}",
100 hex::encode(hmac_sha256(secret.as_bytes(), body))
101 )
102}
103
104pub 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
118pub 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 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; base + base.mul_f64(jitter_permille as f64 / 1000.0)
140}
141
142#[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 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 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 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
219fn 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
230fn 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
257pub 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 || (s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] && v4_at(s[6], s[7]))
290 || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 && v4_at(s[6], s[7]))
292 || (s[0] == 0x2002 && v4_at(s[1], s[2]))
294 || v6 == Ipv6Addr::UNSPECIFIED
295 }
296 }
297}
298
299pub type Lookup = Arc<
304 dyn Fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<IpAddr>>> + Send>>
305 + Send
306 + Sync,
307>;
308
309pub 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
321struct 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
360pub 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#[derive(sqlx::FromRow)]
390struct Claimed {
391 id: Uuid,
392 event: String,
393 payload: Value,
394 attempts: i32,
395 lease_until: chrono::DateTime<chrono::Utc>,
401 url: String,
402 secret: String,
403}
404
405#[derive(Clone, Debug, PartialEq, Eq)]
407pub struct Outcome {
408 pub delivery_id: Uuid,
409 pub status: String,
411 pub attempts: i32,
412 pub status_code: Option<u16>,
413 pub error: String,
414}
415
416#[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#[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
448const 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
461pub 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
471async 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 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 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 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 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 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 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 async fn post(&self, c: &Claimed) -> Result<u16, String> {
639 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 Ok(response.status().as_u16())
657 }
658}
659
660fn 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
684pub 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
719pub 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 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 #[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 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 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 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 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 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 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 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}