1use chrono::{DateTime, Utc};
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6use stateset_core::CommerceEvent;
7use std::collections::{HashMap, VecDeque};
8use std::fmt;
9use std::future::Future;
10use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
11use std::sync::{Arc, RwLock};
12use tokio::sync::Semaphore;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Webhook {
18 pub id: Uuid,
20 pub name: String,
22 pub url: String,
24 pub secret: Option<String>,
26 pub event_types: Vec<String>,
28 pub active: bool,
30 pub headers: HashMap<String, String>,
32 pub created_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum WebhookRegistrationError {
40 UnsafeUrl,
42 DuplicateId,
44 WebhooksDisabled,
46}
47
48impl fmt::Display for WebhookRegistrationError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::UnsafeUrl => {
52 write!(f, "webhook registration rejected: URL validation failed")
53 }
54 Self::DuplicateId => {
55 write!(f, "webhook registration rejected: duplicate webhook id")
56 }
57 Self::WebhooksDisabled => {
58 write!(f, "webhook registration rejected: webhook subsystem is disabled")
59 }
60 }
61 }
62}
63
64impl Webhook {
65 pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
67 Self {
68 id: Uuid::new_v4(),
69 name: name.into(),
70 url: url.into(),
71 secret: None,
72 event_types: Vec::new(),
73 active: true,
74 headers: HashMap::new(),
75 created_at: Utc::now(),
76 }
77 }
78
79 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
81 self.secret = Some(secret.into());
82 self
83 }
84
85 #[must_use]
87 pub fn with_events(mut self, events: Vec<String>) -> Self {
88 self.event_types = events;
89 self
90 }
91
92 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
94 self.headers.insert(key.into(), value.into());
95 self
96 }
97
98 #[must_use]
100 pub fn should_receive(&self, event: &CommerceEvent) -> bool {
101 if !self.active {
102 return false;
103 }
104 if self.event_types.is_empty() {
105 return true;
106 }
107 self.event_types.contains(&event.event_type().to_string())
108 }
109}
110
111#[derive(Debug, Clone)]
113pub struct WebhookConfig {
114 pub max_retries: u32,
115 pub timeout_secs: u64,
116 pub retry_delay_ms: u64,
117 pub max_in_flight: usize,
118 pub max_delivery_history: usize,
120 pub outbound_allowlist: Vec<String>,
130}
131
132impl Default for WebhookConfig {
133 fn default() -> Self {
134 Self {
135 max_retries: 3,
136 timeout_secs: 30,
137 retry_delay_ms: 1000,
138 max_in_flight: 8,
139 max_delivery_history: 1_000,
140 outbound_allowlist: Vec::new(),
141 }
142 }
143}
144
145impl WebhookConfig {
146 pub fn with_outbound_allowlist<I, S>(mut self, entries: I) -> Self
148 where
149 I: IntoIterator<Item = S>,
150 S: AsRef<str>,
151 {
152 self.outbound_allowlist = normalize_allowlist(entries);
153 self
154 }
155}
156
157enum WebhookRuntime {
158 Handle(tokio::runtime::Handle),
159 Owned(tokio::runtime::Runtime),
160 Disabled(String),
161}
162
163impl WebhookRuntime {
164 fn new() -> Self {
165 if let Ok(handle) = tokio::runtime::Handle::try_current() {
166 Self::Handle(handle)
167 } else {
168 match tokio::runtime::Runtime::new() {
169 Ok(runtime) => Self::Owned(runtime),
170 Err(err) => {
171 let fallback =
172 tokio::runtime::Builder::new_current_thread().enable_all().build();
173 match fallback {
174 Ok(runtime) => Self::Owned(runtime),
175 Err(fallback_err) => {
176 let message = format!(
177 "Failed to create webhook runtime: {err}; fallback failed: {fallback_err}"
178 );
179 tracing::error!("{}", message);
180 Self::Disabled(message)
181 }
182 }
183 }
184 }
185 }
186 }
187
188 fn spawn<F>(&self, fut: F)
189 where
190 F: Future + Send + 'static,
191 F::Output: Send + 'static,
192 {
193 match self {
194 Self::Handle(handle) => {
195 handle.spawn(fut);
196 }
197 Self::Owned(runtime) => {
198 runtime.spawn(fut);
199 }
200 Self::Disabled(message) => {
201 tracing::error!("Webhook runtime unavailable: {}", message);
202 }
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct WebhookDelivery {
210 pub id: Uuid,
211 pub webhook_id: Uuid,
212 pub event_type: String,
213 pub event_id: Uuid,
214 pub status: DeliveryStatus,
215 pub attempts: u32,
216 pub last_attempt_at: Option<DateTime<Utc>>,
217 pub response_status: Option<u16>,
218 pub response_body: Option<String>,
219 pub error: Option<String>,
220 pub created_at: DateTime<Utc>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "snake_case")]
226#[non_exhaustive]
227pub enum DeliveryStatus {
228 Pending,
229 Delivered,
230 Failed,
231 Retrying,
232}
233
234pub struct WebhookManager {
236 webhooks: Arc<RwLock<HashMap<Uuid, Webhook>>>,
237 delivery_history: Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
238 config: WebhookConfig,
239 client: reqwest::Client,
240 runtime: WebhookRuntime,
241 delivery_task_limit: Arc<Semaphore>,
242}
243
244impl std::fmt::Debug for WebhookManager {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("WebhookManager").field("config", &self.config).finish_non_exhaustive()
247 }
248}
249
250impl WebhookManager {
251 #[must_use]
253 pub fn new(max_retries: u32, timeout_secs: u64) -> Self {
254 Self::with_config(WebhookConfig { max_retries, timeout_secs, ..Default::default() })
255 }
256
257 #[must_use]
259 pub fn with_config(config: WebhookConfig) -> Self {
260 let mut config = WebhookConfig {
261 max_in_flight: config.max_in_flight.max(1),
262 timeout_secs: config.timeout_secs.max(1),
263 retry_delay_ms: config.retry_delay_ms.max(1),
264 ..config
265 };
266 config.outbound_allowlist = normalize_allowlist(config.outbound_allowlist.iter());
267
268 let client = reqwest::Client::builder()
269 .timeout(std::time::Duration::from_secs(config.timeout_secs))
270 .redirect(reqwest::redirect::Policy::none())
271 .build()
272 .unwrap_or_else(|err| {
273 tracing::error!("Failed to create HTTP client: {}", err);
274 reqwest::Client::new()
275 });
276 let runtime = WebhookRuntime::new();
277 let delivery_task_limit = Arc::new(Semaphore::new(config.max_in_flight.max(1)));
278
279 Self {
280 webhooks: Arc::new(RwLock::new(HashMap::new())),
281 delivery_history: Arc::new(RwLock::new(HashMap::new())),
282 config,
283 client,
284 runtime,
285 delivery_task_limit,
286 }
287 }
288
289 pub fn register(&self, webhook: Webhook) -> Uuid {
295 match self.register_strict(webhook) {
296 Ok(id) => id,
297 Err(err) => {
298 tracing::warn!(error = %err, "Webhook registration fallback returned nil");
299 Uuid::nil()
300 }
301 }
302 }
303
304 pub fn register_strict(&self, webhook: Webhook) -> Result<Uuid, WebhookRegistrationError> {
308 if !is_safe_webhook_url(&webhook.url, &self.config.outbound_allowlist) {
309 tracing::warn!(
310 webhook_id = %webhook.id,
311 webhook_url = %webhook.url,
312 "Rejected webhook registration: unsafe URL"
313 );
314 return Err(WebhookRegistrationError::UnsafeUrl);
315 }
316
317 let id = webhook.id;
318 let mut webhooks = match self.webhooks.write() {
319 Ok(guard) => guard,
320 Err(poison) => {
321 tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
322 poison.into_inner()
323 }
324 };
325 if webhooks.contains_key(&id) {
326 tracing::warn!(webhook_id = %id, "Rejected webhook registration: duplicate id");
327 return Err(WebhookRegistrationError::DuplicateId);
328 }
329 webhooks.insert(id, webhook);
330 tracing::info!(webhook_id = %id, "Webhook registered");
331 Ok(id)
332 }
333
334 pub fn try_register(&self, webhook: Webhook) -> Option<Uuid> {
336 self.register_strict(webhook).ok()
337 }
338
339 pub fn unregister(&self, id: Uuid) -> bool {
341 let mut webhooks = match self.webhooks.write() {
342 Ok(guard) => guard,
343 Err(poison) => {
344 tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
345 poison.into_inner()
346 }
347 };
348 let removed = webhooks.remove(&id).is_some();
349 if removed {
350 let mut delivery_history = match self.delivery_history.write() {
351 Ok(guard) => guard,
352 Err(poison) => {
353 tracing::error!(
354 "WebhookManager delivery history lock poisoned (write); recovering"
355 );
356 poison.into_inner()
357 }
358 };
359 delivery_history.remove(&id);
360 tracing::info!(webhook_id = %id, "Webhook unregistered");
361 }
362 removed
363 }
364
365 pub fn get(&self, id: Uuid) -> Option<Webhook> {
367 let webhooks = match self.webhooks.read() {
368 Ok(guard) => guard,
369 Err(poison) => {
370 tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
371 poison.into_inner()
372 }
373 };
374 webhooks.get(&id).cloned()
375 }
376
377 pub fn list(&self) -> Vec<Webhook> {
379 let webhooks = match self.webhooks.read() {
380 Ok(guard) => guard,
381 Err(poison) => {
382 tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
383 poison.into_inner()
384 }
385 };
386 webhooks.values().cloned().collect()
387 }
388
389 pub fn deliveries(&self, webhook_id: Uuid) -> Vec<WebhookDelivery> {
391 let history = match self.delivery_history.read() {
392 Ok(guard) => guard,
393 Err(poison) => {
394 tracing::error!("WebhookManager delivery history lock poisoned (read); recovering");
395 poison.into_inner()
396 }
397 };
398 history
399 .get(&webhook_id)
400 .map(|entries| entries.iter().rev().cloned().collect())
401 .unwrap_or_default()
402 }
403
404 pub fn update(&self, webhook: Webhook) -> bool {
406 let id = webhook.id;
407 let mut webhooks = match self.webhooks.write() {
408 Ok(guard) => guard,
409 Err(poison) => {
410 tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
411 poison.into_inner()
412 }
413 };
414 if !is_safe_webhook_url(&webhook.url, &self.config.outbound_allowlist) {
415 tracing::warn!(webhook_id = %webhook.id, webhook_url = %webhook.url, "Rejected webhook update: unsafe URL");
416 return false;
417 }
418
419 if let std::collections::hash_map::Entry::Occupied(mut e) = webhooks.entry(id) {
420 e.insert(webhook);
421 true
422 } else {
423 false
424 }
425 }
426
427 pub fn set_active(&self, id: Uuid, active: bool) -> bool {
429 let mut webhooks = match self.webhooks.write() {
430 Ok(guard) => guard,
431 Err(poison) => {
432 tracing::error!("WebhookManager webhooks lock poisoned (write); recovering");
433 poison.into_inner()
434 }
435 };
436 if let Some(webhook) = webhooks.get_mut(&id) {
437 webhook.active = active;
438 true
439 } else {
440 false
441 }
442 }
443
444 #[must_use]
446 pub fn failed_deliveries(&self) -> Vec<WebhookDelivery> {
447 let history = match self.delivery_history.read() {
448 Ok(guard) => guard,
449 Err(poison) => poison.into_inner(),
450 };
451 history
452 .values()
453 .flat_map(|entries| {
454 entries.iter().filter(|d| d.status == DeliveryStatus::Failed).cloned()
455 })
456 .collect()
457 }
458
459 #[must_use]
461 pub fn failed_delivery_count(&self) -> usize {
462 let history = match self.delivery_history.read() {
463 Ok(guard) => guard,
464 Err(poison) => poison.into_inner(),
465 };
466 history
467 .values()
468 .flat_map(|entries| entries.iter())
469 .filter(|d| d.status == DeliveryStatus::Failed)
470 .count()
471 }
472
473 pub fn clear_failed_deliveries(&self, webhook_id: Uuid) -> usize {
475 let mut history = match self.delivery_history.write() {
476 Ok(guard) => guard,
477 Err(poison) => poison.into_inner(),
478 };
479 if let Some(entries) = history.get_mut(&webhook_id) {
480 let before = entries.len();
481 entries.retain(|d| d.status != DeliveryStatus::Failed);
482 before - entries.len()
483 } else {
484 0
485 }
486 }
487
488 pub fn deliver(&self, event: CommerceEvent) {
490 use futures::stream::{self, StreamExt};
491
492 let webhooks_guard = match self.webhooks.read() {
493 Ok(guard) => guard,
494 Err(poison) => {
495 tracing::error!("WebhookManager webhooks lock poisoned (read); recovering");
496 poison.into_inner()
497 }
498 };
499 let webhooks: Vec<Webhook> =
500 webhooks_guard.values().filter(|w| w.should_receive(&event)).cloned().collect();
501
502 if webhooks.is_empty() {
503 return;
504 }
505
506 let client = self.client.clone();
507 let config = self.config.clone();
508 let delivery_history = self.delivery_history.clone();
509 let max_in_flight = config.max_in_flight.max(1);
510 let Some(delivery_permit) = self.delivery_task_limit.clone().try_acquire_owned().ok()
511 else {
512 tracing::warn!(
513 event_type = event.event_type(),
514 "Dropping webhook delivery due to saturated delivery workers"
515 );
516 return;
517 };
518
519 self.runtime.spawn(async move {
520 let _delivery_permit = delivery_permit;
521 let deliveries = stream::iter(webhooks.into_iter().map(|webhook| {
522 let client = client.clone();
523 let event = event.clone();
524 let config = config.clone();
525 let delivery_history = delivery_history.clone();
526 async move {
527 deliver_to_webhook(&client, &webhook, &event, &config, &delivery_history).await;
528 }
529 }));
530
531 deliveries.for_each_concurrent(max_in_flight, |fut| fut).await;
532 });
533 }
534}
535
536async fn deliver_to_webhook(
538 client: &reqwest::Client,
539 webhook: &Webhook,
540 event: &CommerceEvent,
541 config: &WebhookConfig,
542 delivery_history: &Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
543) {
544 let mut delivery = WebhookDelivery {
545 id: Uuid::new_v4(),
546 webhook_id: webhook.id,
547 event_type: event.event_type().to_string(),
548 event_id: Uuid::new_v4(),
549 status: DeliveryStatus::Pending,
550 attempts: 0,
551 last_attempt_at: None,
552 response_status: None,
553 response_body: None,
554 error: None,
555 created_at: Utc::now(),
556 };
557
558 if !is_safe_webhook_url_for_delivery(&webhook.url, &config.outbound_allowlist) {
559 tracing::error!(
560 webhook_id = %webhook.id,
561 webhook_url = %webhook.url,
562 "Skipping webhook delivery: unsafe URL"
563 );
564 delivery.status = DeliveryStatus::Failed;
565 delivery.error = Some("unsafe webhook URL".to_string());
566 append_delivery_record(delivery_history, webhook.id, delivery, config.max_delivery_history);
567 return;
568 }
569
570 let payload = WebhookPayload {
571 id: Uuid::new_v4(),
572 event_type: event.event_type().to_string(),
573 timestamp: event.timestamp(),
574 data: event.clone(),
575 };
576 delivery.event_id = payload.id;
577
578 let body = match serde_json::to_string(&payload) {
579 Ok(b) => b,
580 Err(e) => {
581 tracing::error!(error = %e, "Failed to serialize webhook payload");
582 delivery.status = DeliveryStatus::Failed;
583 delivery.error = Some(format!("failed to serialize payload: {e}"));
584 append_delivery_record(
585 delivery_history,
586 webhook.id,
587 delivery,
588 config.max_delivery_history,
589 );
590 return;
591 }
592 };
593
594 for attempt in 0..=config.max_retries {
595 if attempt > 0 {
596 const MAX_BACKOFF_MS: u64 = 60_000;
598 let exp = attempt.saturating_sub(1);
599 let shift = exp.min(16);
600 let factor = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
601 let delay_ms = config.retry_delay_ms.saturating_mul(factor).min(MAX_BACKOFF_MS);
602 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
603 }
604
605 begin_delivery_attempt(&mut delivery, attempt + 1);
606
607 let mut request = client
608 .post(&webhook.url)
609 .header("Content-Type", "application/json")
610 .header("X-Webhook-ID", webhook.id.to_string())
611 .header("X-Event-Type", event.event_type())
612 .header("X-Delivery-ID", payload.id.to_string())
613 .header("X-Delivery-Attempt", (attempt + 1).to_string());
614
615 if let Some(ref secret) = webhook.secret {
617 if let Some(signature) = compute_signature(secret, &body) {
618 request = request.header("X-Signature", signature);
619 }
620 }
621
622 for (key, value) in &webhook.headers {
624 request = request.header(key, value);
625 }
626
627 match request.body(body.clone()).send().await {
628 Ok(response) => {
629 let status = response.status();
630 delivery.response_status = Some(status.as_u16());
631 if status.is_success() {
632 delivery.status = DeliveryStatus::Delivered;
633 tracing::debug!(
634 webhook_id = %webhook.id,
635 event_type = event.event_type(),
636 status = %status,
637 attempt = attempt + 1,
638 "Webhook delivered successfully"
639 );
640 append_delivery_record(
641 delivery_history,
642 webhook.id,
643 delivery,
644 config.max_delivery_history,
645 );
646 return;
647 } else {
648 let response_body = response.text().await.unwrap_or_default();
649 delivery.response_body = Some(response_body.clone());
650 if attempt >= config.max_retries {
651 delivery.status = DeliveryStatus::Failed;
652 } else {
653 delivery.status = DeliveryStatus::Retrying;
654 }
655 tracing::warn!(
656 webhook_id = %webhook.id,
657 event_type = event.event_type(),
658 status = %status,
659 attempt = attempt + 1,
660 response = %response_body,
661 "Webhook delivery failed with non-success status"
662 );
663 }
664 }
665 Err(e) => {
666 delivery.error = Some(e.to_string());
667 if attempt >= config.max_retries {
668 delivery.status = DeliveryStatus::Failed;
669 } else {
670 delivery.status = DeliveryStatus::Retrying;
671 }
672 tracing::warn!(
673 webhook_id = %webhook.id,
674 event_type = event.event_type(),
675 attempt = attempt + 1,
676 error = %e,
677 "Webhook delivery failed"
678 );
679 }
680 }
681
682 if delivery.status == DeliveryStatus::Failed && attempt >= config.max_retries {
683 break;
684 }
685 }
686
687 append_delivery_record(delivery_history, webhook.id, delivery, config.max_delivery_history);
688 tracing::error!(
689 webhook_id = %webhook.id,
690 event_type = event.event_type(),
691 max_retries = config.max_retries,
692 "Webhook delivery exhausted all retries"
693 );
694}
695
696fn begin_delivery_attempt(delivery: &mut WebhookDelivery, attempt: u32) {
697 delivery.attempts = attempt;
698 delivery.last_attempt_at = Some(Utc::now());
699 delivery.response_status = None;
701 delivery.response_body = None;
702 delivery.error = None;
703}
704
705fn append_delivery_record(
706 delivery_history: &Arc<RwLock<HashMap<Uuid, VecDeque<WebhookDelivery>>>>,
707 webhook_id: Uuid,
708 delivery: WebhookDelivery,
709 max_delivery_history: usize,
710) {
711 let mut history = match delivery_history.write() {
712 Ok(guard) => guard,
713 Err(poison) => {
714 tracing::error!("WebhookManager delivery history lock poisoned (write); recovering");
715 poison.into_inner()
716 }
717 };
718 let entries = history.entry(webhook_id).or_insert_with(VecDeque::new);
719 entries.push_back(delivery);
720
721 while entries.len() > max_delivery_history {
722 entries.pop_front();
723 }
724}
725
726fn normalize_allowlist<I, S>(entries: I) -> Vec<String>
727where
728 I: IntoIterator<Item = S>,
729 S: AsRef<str>,
730{
731 let mut normalized = Vec::new();
732 for entry in entries {
733 let candidate = normalize_host_match_value(entry.as_ref());
734 if candidate.is_empty() {
735 continue;
736 }
737 if !normalized.contains(&candidate) {
738 normalized.push(candidate);
739 }
740 }
741 normalized
742}
743
744fn normalize_host_match_value(value: &str) -> String {
745 let trimmed = value.trim();
746 if let Some(suffix) = trimmed.strip_prefix("*.") {
747 let suffix = suffix.trim_end_matches('.').to_ascii_lowercase();
748 if suffix.is_empty() {
749 return String::new();
750 }
751 return format!("*.{suffix}");
752 }
753 trimmed.trim_end_matches('.').to_ascii_lowercase()
754}
755
756fn host_matches_allowlist(host: &str, allowlist: &[String]) -> bool {
757 let normalized_host = normalize_host_match_value(host);
758
759 allowlist.iter().any(|entry| allowlist_entry_matches(normalized_host.as_str(), entry.as_str()))
760}
761
762fn allowlist_entry_matches(host: &str, entry: &str) -> bool {
763 if let Some(suffix) = entry.strip_prefix("*.") {
764 return host.len() > suffix.len()
766 && host.ends_with(suffix)
767 && host
768 .as_bytes()
769 .get(host.len().saturating_sub(suffix.len() + 1))
770 .is_some_and(|b| *b == b'.');
771 }
772
773 host == entry
774}
775
776fn is_safe_webhook_url(url: &str, outbound_allowlist: &[String]) -> bool {
777 is_safe_webhook_url_with_dns_check(url, false, outbound_allowlist)
782}
783
784fn is_safe_webhook_url_for_delivery(url: &str, outbound_allowlist: &[String]) -> bool {
785 is_safe_webhook_url_with_dns_check(url, true, outbound_allowlist)
786}
787
788pub(crate) fn validate_outbound_webhook_target_url(
789 url: &str,
790 outbound_allowlist: &[String],
791) -> Result<(), String> {
792 if is_safe_webhook_url_for_delivery(url, outbound_allowlist) {
793 Ok(())
794 } else {
795 Err("URL failed outbound webhook safety validation".to_string())
796 }
797}
798
799fn is_safe_webhook_url_with_dns_check(
800 url: &str,
801 resolve_dns: bool,
802 outbound_allowlist: &[String],
803) -> bool {
804 is_safe_webhook_url_with_dns_resolver(url, resolve_dns, outbound_allowlist, &resolve_host_ips)
805}
806
807fn is_safe_webhook_url_with_dns_resolver<F>(
808 url: &str,
809 resolve_dns: bool,
810 outbound_allowlist: &[String],
811 dns_resolver: &F,
812) -> bool
813where
814 F: Fn(&str, u16) -> Result<Vec<IpAddr>, String>,
815{
816 let parsed = match Url::parse(url) {
817 Ok(parsed) => parsed,
818 Err(err) => {
819 tracing::warn!(webhook_url = %url, error = %err, "Webhook URL parse failed");
820 return false;
821 }
822 };
823
824 if !matches!(parsed.scheme(), "http" | "https") {
825 tracing::warn!(webhook_url = %url, scheme = %parsed.scheme(), "Webhook URL scheme rejected");
826 return false;
827 }
828
829 if parsed.password().is_some() || !parsed.username().is_empty() {
830 tracing::warn!(webhook_url = %url, "Webhook URL userinfo rejected");
831 return false;
832 }
833
834 let host = if let Some(host) = parsed.host_str() {
835 host
836 } else {
837 tracing::warn!(webhook_url = %url, "Webhook URL host missing");
838 return false;
839 };
840
841 if is_localhostish_host(host) {
842 tracing::warn!(webhook_url = %url, "Webhook URL host rejected (localhost-like)");
843 return false;
844 }
845
846 let normalized_host = host.trim().trim_matches('[').trim_matches(']').to_ascii_lowercase();
850 if normalized_host.contains("::ffff:") {
851 tracing::warn!(webhook_url = %url, "Webhook URL host rejected (IPv6-mapped IPv4 literal)");
852 return false;
853 }
854
855 if let Some(mapped_v4) = parse_ipv6_mapped_ipv4_host(host) {
859 if is_public_ipv4(mapped_v4) {
860 return true;
861 }
862 tracing::warn!(
863 webhook_url = %url,
864 mapped_ipv4 = %mapped_v4,
865 "Webhook URL host rejected (IPv6-mapped non-public IPv4)"
866 );
867 return false;
868 }
869
870 if parse_decimal_ipv4_literal(host).is_some() || is_ambiguous_ipv4_encoding_host(host) {
871 tracing::warn!(webhook_url = %url, "Webhook URL host rejected (ambiguous IPv4 encoding)");
872 return false;
873 }
874
875 if !outbound_allowlist.is_empty() && !host_matches_allowlist(host, outbound_allowlist) {
876 tracing::warn!(webhook_url = %url, "Webhook URL host rejected (not in outbound allowlist)");
877 return false;
878 }
879
880 let host_for_ip_parse = host.trim().trim_matches('[').trim_matches(']');
881 if let Ok(ipv6) = host_for_ip_parse.parse::<Ipv6Addr>() {
882 if is_public_ipv6(ipv6) {
883 return true;
884 }
885 tracing::warn!(webhook_url = %url, ip = %ipv6, "Webhook URL host IP rejected");
886 return false;
887 }
888 if let Ok(ipv4) = host_for_ip_parse.parse::<Ipv4Addr>() {
889 if is_public_ipv4(ipv4) {
890 return true;
891 }
892 tracing::warn!(webhook_url = %url, ip = %ipv4, "Webhook URL host IP rejected");
893 return false;
894 }
895
896 if resolve_dns {
897 is_public_hostname_with_resolver(
898 host,
899 parsed.port_or_known_default().unwrap_or(443),
900 dns_resolver,
901 )
902 } else {
903 true
904 }
905}
906
907fn parse_ipv6_mapped_ipv4_host(host: &str) -> Option<Ipv4Addr> {
908 let normalized = host.trim().trim_matches('[').trim_matches(']').to_ascii_lowercase();
909 let mapped = normalized.strip_prefix("::ffff:")?;
910 mapped.parse::<Ipv4Addr>().ok()
911}
912
913fn is_localhostish_host(host: &str) -> bool {
914 let normalized = host.trim().trim_end_matches('.').to_ascii_lowercase();
915
916 normalized == "localhost"
917 || normalized == "localhost.localdomain"
918 || normalized.ends_with(".localhost")
919 || normalized.ends_with(".local")
920}
921
922fn is_public_hostname_with_resolver<F>(host: &str, port: u16, dns_resolver: &F) -> bool
923where
924 F: Fn(&str, u16) -> Result<Vec<IpAddr>, String>,
925{
926 let resolved = match dns_resolver(host, port) {
927 Ok(ips) => ips,
928 Err(err) => {
929 tracing::warn!(webhook_host = %host, error = %err, "Webhook hostname DNS resolution failed");
930 return false;
931 }
932 };
933
934 if resolved.is_empty() {
935 tracing::warn!(webhook_host = %host, "Webhook hostname did not resolve to any address");
936 return false;
937 }
938
939 for ip in &resolved {
940 if !is_public_ip(*ip) {
941 tracing::warn!(webhook_host = %host, ip = %ip, "Webhook hostname resolves to private or local network addresses");
942 return false;
943 }
944 }
945
946 true
947}
948
949fn resolve_host_ips(host: &str, port: u16) -> Result<Vec<IpAddr>, String> {
950 match (host, port).to_socket_addrs() {
951 Ok(addrs) => {
952 let mut ips = Vec::new();
953 for socket_addr in addrs {
954 let ip = socket_addr.ip();
955 if !ips.contains(&ip) {
956 ips.push(ip);
957 }
958 }
959 Ok(ips)
960 }
961 Err(err) => Err(err.to_string()),
962 }
963}
964
965fn parse_decimal_ipv4_literal(host: &str) -> Option<Ipv4Addr> {
966 if !host.bytes().all(|b| b.is_ascii_digit()) {
967 return None;
968 }
969 let n = host.parse::<u32>().ok()?;
970 Some(Ipv4Addr::from(n))
971}
972
973fn is_ambiguous_ipv4_encoding_host(host: &str) -> bool {
974 let labels: Vec<&str> = host.split('.').collect();
975 if labels.is_empty() || labels.len() > 4 {
976 return false;
977 }
978
979 let mut saw_hex = false;
980 for label in &labels {
981 if label.is_empty() {
982 return false;
983 }
984
985 if let Some(hex_digits) = label.strip_prefix("0x") {
986 if hex_digits.is_empty() || !hex_digits.bytes().all(|b| b.is_ascii_hexdigit()) {
987 return false;
988 }
989 saw_hex = true;
990 continue;
991 }
992
993 if !label.bytes().all(|b| b.is_ascii_digit()) {
994 return false;
995 }
996 }
997
998 if labels.len() == 1 {
999 return saw_hex;
1000 }
1001
1002 saw_hex
1003 || labels.len() < 4
1004 || labels.iter().any(|label| label.len() > 1 && label.starts_with('0'))
1005}
1006
1007fn is_public_ip(ip: IpAddr) -> bool {
1008 match ip {
1009 IpAddr::V4(addr) => is_public_ipv4(addr),
1010 IpAddr::V6(addr) => is_public_ipv6(addr),
1011 }
1012}
1013
1014fn is_public_ipv4(addr: Ipv4Addr) -> bool {
1015 let octets = addr.octets();
1016
1017 if addr.is_loopback()
1019 || addr.is_private()
1020 || addr.is_link_local()
1021 || addr.is_multicast()
1022 || addr.is_unspecified()
1023 || addr.is_broadcast()
1024 || addr.is_documentation()
1025 {
1026 return false;
1027 }
1028
1029 if octets[0] == 0 {
1031 return false;
1032 }
1033
1034 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
1036 return false;
1037 }
1038
1039 if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
1041 return false;
1042 }
1043
1044 if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
1046 return false;
1047 }
1048
1049 if octets[0] >= 240 {
1051 return false;
1052 }
1053
1054 true
1055}
1056
1057const fn is_public_ipv6(addr: Ipv6Addr) -> bool {
1058 if addr.to_ipv4_mapped().is_some() {
1059 return false;
1060 }
1061
1062 let segments = addr.segments();
1063 let first = segments[0];
1064
1065 let is_unique_local = (first & 0xfe00) == 0xfc00;
1067 let is_link_local = (first & 0xffc0) == 0xfe80;
1068 let is_site_local = (first & 0xffc0) == 0xfec0;
1069 let is_documentation = first == 0x2001 && segments[1] == 0x0db8;
1071
1072 !addr.is_loopback()
1073 && !addr.is_unspecified()
1074 && !addr.is_multicast()
1075 && !is_unique_local
1076 && !is_link_local
1077 && !is_site_local
1078 && !is_documentation
1079}
1080
1081#[derive(Debug, Serialize, Deserialize)]
1083struct WebhookPayload {
1084 id: Uuid,
1085 event_type: String,
1086 timestamp: DateTime<Utc>,
1087 data: CommerceEvent,
1088}
1089
1090fn compute_signature(secret: &str, body: &str) -> Option<String> {
1092 use hmac::{Hmac, Mac};
1093 use sha2::Sha256;
1094
1095 type HmacSha256 = Hmac<Sha256>;
1096
1097 let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
1098 Ok(mac) => mac,
1099 Err(err) => {
1100 tracing::error!(error = %err, "Failed to initialize webhook HMAC");
1101 return None;
1102 }
1103 };
1104 mac.update(body.as_bytes());
1105 let result = mac.finalize();
1106
1107 Some(format!("sha256={}", hex::encode(result.into_bytes())))
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 #[test]
1115 fn test_webhook_creation() {
1116 let webhook = Webhook::new("Test Hook", "https://example.com/webhook")
1117 .with_secret("mysecret")
1118 .with_events(vec!["order_created".to_string()])
1119 .with_header("X-Custom", "value");
1120
1121 assert_eq!(webhook.name, "Test Hook");
1122 assert_eq!(webhook.url, "https://example.com/webhook");
1123 assert_eq!(webhook.secret, Some("mysecret".to_string()));
1124 assert_eq!(webhook.event_types, vec!["order_created"]);
1125 assert_eq!(webhook.headers.get("X-Custom"), Some(&"value".to_string()));
1126 }
1127
1128 #[test]
1129 fn test_webhook_should_receive() {
1130 use chrono::Utc;
1131 use rust_decimal_macros::dec;
1132
1133 let webhook = Webhook::new("Test", "https://example.com")
1134 .with_events(vec!["order_created".to_string()]);
1135
1136 let order_event = CommerceEvent::OrderCreated {
1137 order_id: stateset_core::OrderId::new(),
1138 customer_id: stateset_core::CustomerId::new(),
1139 total_amount: dec!(100),
1140 item_count: 1,
1141 timestamp: Utc::now(),
1142 };
1143
1144 let customer_event = CommerceEvent::CustomerCreated {
1145 customer_id: stateset_core::CustomerId::new(),
1146 email: "test@example.com".to_string(),
1147 timestamp: Utc::now(),
1148 };
1149
1150 assert!(webhook.should_receive(&order_event));
1151 assert!(!webhook.should_receive(&customer_event));
1152 }
1153
1154 #[test]
1155 fn test_compute_signature() {
1156 let signature = compute_signature("secret", "test body").expect("signature");
1157 assert!(signature.starts_with("sha256="));
1158 }
1159
1160 #[test]
1161 fn test_webhook_register_rejects_localhost() {
1162 let manager = WebhookManager::new(3, 30);
1163 let webhook = Webhook::new("Localhost Hook", "http://localhost:8080/webhook");
1164
1165 assert_eq!(
1166 manager.register_strict(webhook.clone()),
1167 Err(WebhookRegistrationError::UnsafeUrl)
1168 );
1169 assert!(manager.try_register(webhook).is_none());
1170 assert!(manager.list().is_empty());
1171 }
1172
1173 #[test]
1174 fn test_webhook_register_rejects_private_ip() {
1175 let manager = WebhookManager::new(3, 30);
1176 let webhook = Webhook::new("Private IP Hook", "https://10.0.0.1/callback");
1177
1178 assert_eq!(
1179 manager.register_strict(webhook.clone()),
1180 Err(WebhookRegistrationError::UnsafeUrl)
1181 );
1182 assert!(manager.try_register(webhook).is_none());
1183 assert!(manager.list().is_empty());
1184 }
1185
1186 #[test]
1187 fn test_webhook_register_rejects_ipv6_mapped_ipv4_loopback() {
1188 let manager = WebhookManager::new(3, 30);
1189 let webhook = Webhook::new("IPv6 mapped loopback hook", "https://[::ffff:127.0.0.1]/cb");
1190
1191 assert_eq!(
1192 manager.register_strict(webhook.clone()),
1193 Err(WebhookRegistrationError::UnsafeUrl)
1194 );
1195 assert!(manager.try_register(webhook).is_none());
1196 }
1197
1198 #[test]
1199 fn test_webhook_register_rejects_ipv6_mapped_ipv4_private() {
1200 let manager = WebhookManager::new(3, 30);
1201 let webhook = Webhook::new("IPv6 mapped private hook", "https://[::ffff:10.0.0.1]/cb");
1202
1203 assert_eq!(
1204 manager.register_strict(webhook.clone()),
1205 Err(WebhookRegistrationError::UnsafeUrl)
1206 );
1207 assert!(manager.try_register(webhook).is_none());
1208 }
1209
1210 #[test]
1211 fn test_webhook_register_rejects_shared_ipv4_space() {
1212 let manager = WebhookManager::new(3, 30);
1213 let webhook = Webhook::new("CGNAT hook", "https://100.64.0.1/cb");
1214
1215 assert_eq!(
1216 manager.register_strict(webhook.clone()),
1217 Err(WebhookRegistrationError::UnsafeUrl)
1218 );
1219 assert!(manager.try_register(webhook).is_none());
1220 }
1221
1222 #[test]
1223 fn test_webhook_register_accepts_public_ipv6() {
1224 let manager = WebhookManager::new(3, 30);
1225 let webhook = Webhook::new("Public IPv6 Hook", "https://[2606:4700:4700::1111]/callback");
1226 let id = manager.try_register(webhook).expect("expected public IPv6 to be accepted");
1227 assert_ne!(id, Uuid::nil());
1228 }
1229
1230 #[test]
1231 fn test_webhook_config_normalizes_runtime_limits() {
1232 let manager = WebhookManager::with_config(WebhookConfig {
1233 max_retries: 4,
1234 timeout_secs: 0,
1235 max_in_flight: 0,
1236 retry_delay_ms: 0,
1237 max_delivery_history: 7,
1238 outbound_allowlist: vec![
1239 " API.EXAMPLE.COM. ".to_string(),
1240 "*.Example.com.".to_string(),
1241 ],
1242 });
1243
1244 assert_eq!(manager.config.max_retries, 4);
1245 assert_eq!(manager.config.timeout_secs, 1);
1246 assert_eq!(manager.config.max_in_flight, 1);
1247 assert_eq!(manager.config.retry_delay_ms, 1);
1248 assert_eq!(manager.config.max_delivery_history, 7);
1249 assert_eq!(
1250 manager.config.outbound_allowlist,
1251 vec!["api.example.com".to_string(), "*.example.com".to_string()]
1252 );
1253 }
1254
1255 #[test]
1256 fn test_webhook_register_accepts_public_ip() {
1257 let manager = WebhookManager::new(3, 30);
1258 let webhook = Webhook::new("Public IP Hook", "https://1.1.1.1/callback");
1259
1260 let id = manager.try_register(webhook).expect("expected public IP to be accepted");
1261 let registered = manager.get(id).expect("registered webhook should exist");
1262 assert_eq!(registered.url, "https://1.1.1.1/callback");
1263 }
1264
1265 #[test]
1266 fn test_webhook_register_rejects_non_http_scheme() {
1267 let manager = WebhookManager::new(3, 30);
1268 let webhook = Webhook::new("FTP Hook", "ftp://example.com/webhook");
1269
1270 assert_eq!(
1271 manager.register_strict(webhook.clone()),
1272 Err(WebhookRegistrationError::UnsafeUrl)
1273 );
1274 assert!(manager.try_register(webhook).is_none());
1275 }
1276
1277 #[test]
1278 fn test_webhook_register_rejects_userinfo() {
1279 let manager = WebhookManager::new(3, 30);
1280 let webhook = Webhook::new("Userinfo Hook", "https://user:pass@example.com/webhook");
1281
1282 assert_eq!(
1283 manager.register_strict(webhook.clone()),
1284 Err(WebhookRegistrationError::UnsafeUrl)
1285 );
1286 assert!(manager.try_register(webhook).is_none());
1287 }
1288
1289 #[test]
1290 fn test_webhook_register_rejects_invalid_url() {
1291 let manager = WebhookManager::new(3, 30);
1292 let webhook = Webhook::new("Bad URL Hook", "not-a-url");
1293
1294 assert_eq!(
1295 manager.register_strict(webhook.clone()),
1296 Err(WebhookRegistrationError::UnsafeUrl)
1297 );
1298 assert!(manager.try_register(webhook).is_none());
1299 }
1300
1301 #[test]
1302 fn test_webhook_deliveries_keeps_newest_first_and_respects_limit() {
1303 let manager = WebhookManager::with_config(WebhookConfig {
1304 max_delivery_history: 2,
1305 ..Default::default()
1306 });
1307 let webhook = Webhook::new("History Hook", "https://example.com/webhook");
1308 let webhook_id = webhook.id;
1309
1310 let mk_delivery = |record_id: Uuid, status: DeliveryStatus| WebhookDelivery {
1311 id: record_id,
1312 webhook_id,
1313 event_type: "order_created".to_string(),
1314 event_id: Uuid::new_v4(),
1315 status,
1316 attempts: 1,
1317 last_attempt_at: None,
1318 response_status: None,
1319 response_body: None,
1320 error: None,
1321 created_at: Utc::now(),
1322 };
1323
1324 let first = Uuid::new_v4();
1325 let second = Uuid::new_v4();
1326 let third = Uuid::new_v4();
1327
1328 append_delivery_record(
1329 &manager.delivery_history,
1330 webhook_id,
1331 mk_delivery(first, DeliveryStatus::Pending),
1332 2,
1333 );
1334 append_delivery_record(
1335 &manager.delivery_history,
1336 webhook_id,
1337 mk_delivery(second, DeliveryStatus::Delivered),
1338 2,
1339 );
1340 append_delivery_record(
1341 &manager.delivery_history,
1342 webhook_id,
1343 mk_delivery(third, DeliveryStatus::Failed),
1344 2,
1345 );
1346
1347 let deliveries = manager.deliveries(webhook_id);
1348 assert_eq!(deliveries.len(), 2);
1349 assert_eq!(deliveries[0].id, third);
1350 assert_eq!(deliveries[1].id, second);
1351 }
1352
1353 #[test]
1354 fn test_webhook_deliveries_respects_zero_cap() {
1355 let manager = WebhookManager::with_config(WebhookConfig {
1356 max_delivery_history: 0,
1357 ..Default::default()
1358 });
1359 let webhook = Webhook::new("No History Hook", "https://example.com/webhook");
1360 let webhook_id = webhook.id;
1361
1362 append_delivery_record(
1363 &manager.delivery_history,
1364 webhook_id,
1365 WebhookDelivery {
1366 id: Uuid::new_v4(),
1367 webhook_id,
1368 event_type: "order_created".to_string(),
1369 event_id: Uuid::new_v4(),
1370 status: DeliveryStatus::Delivered,
1371 attempts: 1,
1372 last_attempt_at: None,
1373 response_status: None,
1374 response_body: None,
1375 error: None,
1376 created_at: Utc::now(),
1377 },
1378 0,
1379 );
1380
1381 assert_eq!(manager.deliveries(webhook_id).len(), 0);
1382 }
1383
1384 #[test]
1385 fn test_webhook_unregister_clears_delivery_history() {
1386 let manager = WebhookManager::with_config(WebhookConfig {
1387 max_delivery_history: 3,
1388 ..Default::default()
1389 });
1390 let webhook = Webhook::new("History Hook", "https://example.com/webhook");
1391 let webhook_id = webhook.id;
1392 manager.try_register(webhook).expect("webhook should register");
1393
1394 append_delivery_record(
1395 &manager.delivery_history,
1396 webhook_id,
1397 WebhookDelivery {
1398 id: Uuid::new_v4(),
1399 webhook_id,
1400 event_type: "order_created".to_string(),
1401 event_id: Uuid::new_v4(),
1402 status: DeliveryStatus::Delivered,
1403 attempts: 1,
1404 last_attempt_at: None,
1405 response_status: None,
1406 response_body: None,
1407 error: None,
1408 created_at: Utc::now(),
1409 },
1410 3,
1411 );
1412
1413 assert_eq!(manager.deliveries(webhook_id).len(), 1);
1414
1415 assert!(manager.unregister(webhook_id));
1416 assert_eq!(manager.deliveries(webhook_id).len(), 0);
1417 assert!(manager.list().is_empty());
1418 }
1419
1420 #[test]
1421 fn test_webhook_register_fallback_returns_nil() {
1422 let manager = WebhookManager::new(3, 30);
1423 let webhook = Webhook::new("Bad URL Hook", "gopher://127.0.0.1/webhook");
1424 assert_eq!(
1425 manager.register_strict(webhook.clone()),
1426 Err(WebhookRegistrationError::UnsafeUrl)
1427 );
1428 let id = manager.register(webhook);
1429
1430 assert_eq!(id, Uuid::nil());
1431 assert!(manager.list().is_empty());
1432 }
1433
1434 #[test]
1435 fn test_webhook_register_rejects_duplicate_id() {
1436 let manager = WebhookManager::new(3, 30);
1437 let webhook = Webhook::new("Primary", "https://example.com/webhook");
1438 let duplicate = webhook.clone();
1439
1440 let first_id = manager.register_strict(webhook).unwrap();
1441 assert_eq!(first_id, duplicate.id);
1442 assert_eq!(manager.register_strict(duplicate), Err(WebhookRegistrationError::DuplicateId));
1443 assert_eq!(manager.list().len(), 1);
1444 }
1445
1446 #[test]
1447 fn test_begin_delivery_attempt_clears_stale_metadata() {
1448 let mut delivery = WebhookDelivery {
1449 id: Uuid::new_v4(),
1450 webhook_id: Uuid::new_v4(),
1451 event_type: "order_created".to_string(),
1452 event_id: Uuid::new_v4(),
1453 status: DeliveryStatus::Retrying,
1454 attempts: 1,
1455 last_attempt_at: Some(Utc::now()),
1456 response_status: Some(500),
1457 response_body: Some("bad gateway".to_string()),
1458 error: Some("connection refused".to_string()),
1459 created_at: Utc::now(),
1460 };
1461
1462 begin_delivery_attempt(&mut delivery, 2);
1463
1464 assert_eq!(delivery.attempts, 2);
1465 assert!(delivery.last_attempt_at.is_some());
1466 assert_eq!(delivery.response_status, None);
1467 assert_eq!(delivery.response_body, None);
1468 assert_eq!(delivery.error, None);
1469 }
1470
1471 #[test]
1472 fn test_webhook_register_rejects_decimal_ipv4_literal_encoding() {
1473 let manager = WebhookManager::new(3, 30);
1474 let webhook = Webhook::new("Decimal Literal", "https://2130706433/callback");
1475 assert_eq!(manager.try_register(webhook), None);
1476 }
1477
1478 #[test]
1479 fn test_webhook_register_rejects_hex_ipv4_literal_encoding() {
1480 let manager = WebhookManager::new(3, 30);
1481 let webhook = Webhook::new("Hex Literal", "https://0x7f000001/callback");
1482 assert_eq!(manager.try_register(webhook), None);
1483 }
1484
1485 #[test]
1486 fn test_webhook_register_rejects_octal_ipv4_literal_encoding() {
1487 let manager = WebhookManager::new(3, 30);
1488 let webhook = Webhook::new("Octal Literal", "https://0177.0.0.1/callback");
1489 assert_eq!(manager.try_register(webhook), None);
1490 }
1491
1492 #[test]
1493 fn test_webhook_register_rejects_short_ipv4_literal_encoding() {
1494 let manager = WebhookManager::new(3, 30);
1495 let webhook = Webhook::new("Short Literal", "https://127.1/callback");
1496 assert_eq!(manager.try_register(webhook), None);
1497 }
1498
1499 #[test]
1500 fn test_webhook_register_rejects_link_local_ipv6() {
1501 let manager = WebhookManager::new(3, 30);
1502 let webhook = Webhook::new("Link Local", "https://[fe80::1]/callback");
1503 assert_eq!(manager.try_register(webhook), None);
1504 }
1505
1506 #[test]
1507 fn test_webhook_register_rejects_reserved_documentation_ipv4() {
1508 let manager = WebhookManager::new(3, 30);
1509 let webhook = Webhook::new("Doc IPv4", "https://203.0.113.9/callback");
1510 assert_eq!(manager.try_register(webhook), None);
1511 }
1512
1513 #[test]
1514 fn test_webhook_register_rejects_userinfo_confusion_variant() {
1515 let manager = WebhookManager::new(3, 30);
1516 let webhook = Webhook::new("Userinfo Confusion", "https://safe.example.com@127.0.0.1/cb");
1517 assert_eq!(manager.try_register(webhook), None);
1518 }
1519
1520 #[test]
1521 fn test_webhook_register_rejects_malformed_authority() {
1522 let manager = WebhookManager::new(3, 30);
1523 let webhook = Webhook::new("Malformed", "https://[::1/callback");
1524 assert_eq!(manager.try_register(webhook), None);
1525 }
1526
1527 #[test]
1528 fn test_webhook_allowlist_allows_exact_host() {
1529 let manager = WebhookManager::with_config(
1530 WebhookConfig::default().with_outbound_allowlist(["api.example.com"]),
1531 );
1532 let webhook = Webhook::new("Allowlisted", "https://api.example.com/callback");
1533 assert!(manager.try_register(webhook).is_some());
1534 }
1535
1536 #[test]
1537 fn test_webhook_allowlist_blocks_non_listed_host() {
1538 let manager = WebhookManager::with_config(
1539 WebhookConfig::default().with_outbound_allowlist(["api.example.com"]),
1540 );
1541 let webhook = Webhook::new("Blocked", "https://other.example.com/callback");
1542 assert_eq!(manager.try_register(webhook), None);
1543 }
1544
1545 #[test]
1546 fn test_webhook_allowlist_supports_wildcard_subdomain_only() {
1547 let manager = WebhookManager::with_config(
1548 WebhookConfig::default().with_outbound_allowlist(["*.example.com"]),
1549 );
1550 assert!(
1551 manager
1552 .try_register(Webhook::new("Subdomain", "https://hooks.example.com/callback"))
1553 .is_some()
1554 );
1555 assert_eq!(
1556 manager.try_register(Webhook::new("Apex", "https://example.com/callback")),
1557 None
1558 );
1559 }
1560
1561 #[test]
1562 fn test_webhook_allowlist_cannot_override_private_ip_rejection() {
1563 let manager = WebhookManager::with_config(
1564 WebhookConfig::default().with_outbound_allowlist(["127.0.0.1"]),
1565 );
1566 let webhook = Webhook::new("Private", "https://127.0.0.1/callback");
1567 assert_eq!(manager.try_register(webhook), None);
1568 }
1569
1570 #[test]
1571 fn test_webhook_dns_rebinding_style_resolution_mixed_ips_is_rejected() {
1572 let allowlist = vec!["api.example.com".to_string()];
1573 let is_safe = is_safe_webhook_url_with_dns_resolver(
1574 "https://api.example.com/callback",
1575 true,
1576 &allowlist,
1577 &|_host, _port| {
1578 Ok(vec![
1579 IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
1580 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
1581 ])
1582 },
1583 );
1584 assert!(!is_safe);
1585 }
1586
1587 #[test]
1588 fn test_webhook_dns_rebinding_style_resolution_all_public_is_allowed() {
1589 let allowlist = vec!["api.example.com".to_string()];
1590 let is_safe = is_safe_webhook_url_with_dns_resolver(
1591 "https://api.example.com/callback",
1592 true,
1593 &allowlist,
1594 &|_host, _port| {
1595 Ok(vec![
1596 IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
1597 IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
1598 ])
1599 },
1600 );
1601 assert!(is_safe);
1602 }
1603}